added expenses Reimburse

This commit is contained in:
pramod mahajan 2025-08-01 00:42:51 +05:30
parent b30940c2aa
commit 35f221038d
11 changed files with 657 additions and 383 deletions

View File

@ -73,21 +73,6 @@ const ExpenseFilterPanel = ({ onApply, handleGroupBy }) => {
return (
<>
<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>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="p-2 text-start">
@ -174,6 +159,21 @@ const ExpenseFilterPanel = ({ onApply, handleGroupBy }) => {
</div>
</div>
</div>
<div className="mb-2 text-start ">
<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="d-flex justify-content-end py-3 gap-2">
<button
@ -189,6 +189,7 @@ const ExpenseFilterPanel = ({ onApply, handleGroupBy }) => {
</div>
</form>
</FormProvider>
</>
);
};

View File

@ -25,7 +25,6 @@ export const ExpenseSchema = (expenseTypes) => {
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);
@ -65,9 +64,7 @@ export const ExpenseSchema = (expenseTypes) => {
})
)
.nonempty({ message: "At least one file attachment is required" }),
reimburseTransactionId: z.string().optional(),
reimburseDate: z.string().optional(),
reimburseById: z.string().optional(),
})
.refine(
@ -132,10 +129,53 @@ export const defaultExpense = {
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" }),
});
export const ExpenseActionScheam = (isReimbursement = false) => {
return z
.object({
comment: z.string().min(1, { message: "Please leave comment" }),
statusId: z.string().min(1, { message: "Please select a status" }),
reimburseTransactionId: z.string().nullable().optional(),
reimburseDate: z.string().nullable().optional(),
reimburseById: z.string().nullable().optional(),
})
.superRefine((data, ctx) => {
if (isReimbursement) {
if (!data.reimburseTransactionId?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["reimburseTransactionId"],
message: "Reimburse Transaction ID is required",
});
}
if (!data.reimburseDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["reimburseDate"],
message: "Reimburse Date is required",
});
}
if (!data.reimburseById) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["reimburseById"],
message: "Reimburse By is required",
});
}
}
});
};
export const defaultActionValues = {
comment: "",
statusId: "",
reimburseTransactionId: null,
reimburseDate: null,
reimburseById: null,
};
export const SearchSchema = z.object({
projectIds: z.array(z.string()).optional(),

View File

@ -206,7 +206,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
<form id="expenseForm" onSubmit={handleSubmit(onSubmit)}>
<div className="row my-2">
<div className="col-md-6">
<label htmlFor="projectId" className="form-label">
<label className="form-label">
Select Project
</label>
<select
@ -230,7 +230,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
</div>
<div className="col-md-6">
<label for="expensesTypeId" className="form-label ">
<label htmlFor="expensesTypeId" className="form-label ">
Expense Type
</label>
<select
@ -261,7 +261,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
<div className="row my-2">
<div className="col-md-6">
<label for="paymentModeId" className="form-label ">
<label htmlFor="paymentModeId" className="form-label ">
Payment Mode
</label>
<select
@ -290,7 +290,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
</div>
<div className="col-md-6">
<label for="paidById" className="form-label ">
<label htmlFor="paidById" className="form-label ">
Paid By
</label>
<select
@ -320,7 +320,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
<div className="row my-2">
<div className="col-md-6">
<label for="transactionDate" className="form-label ">
<label htmlFor="transactionDate" className="form-label ">
Transaction Date
</label>
{/* <input
@ -343,7 +343,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
</div>
<div className="col-md-6">
<label for="amount" className="form-label ">
<label htmlFor="amount" className="form-label ">
Amount
</label>
<input
@ -361,9 +361,9 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
</div>
</div>
<div class="row my-2">
<div className="row my-2">
<div className="col-md-6">
<label for="supplerName" className="form-label ">
<label htmlFor="supplerName" className="form-label ">
Supplier Name
</label>
<input
@ -380,7 +380,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
</div>
<div className="col-md-6">
<label for="location" className="form-label ">
<label htmlFor="location" className="form-label ">
Location
</label>
<input
@ -396,7 +396,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
</div>
<div className="row my-2">
<div className="col-md-6">
<label for="statusId" className="form-label ">
<label htmlFor="statusId" className="form-label ">
TransactionId
</label>
<input
@ -415,7 +415,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
{ExpenseType?.noOfPersonsRequired && (
<div className="col-md-6">
<label htmlFor="noOfPersons" >
<label >
No. of Persons
</label>
<input
@ -436,7 +436,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
<div className="row my-2">
<div className="col-md-12" >
<label for="description">Description</label>
<label htmlFor="description">Description</label>
<textarea
id="description"
className="form-control form-control-sm"

View File

@ -7,35 +7,43 @@ import {
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ActionSchema } from "./ExpenseSchema";
import { defaultActionValues, ExpenseActionScheam } from "./ExpenseSchema";
import { useExpenseContext } from "../../pages/Expense/ExpensePage";
import { getColorNameFromHex } from "../../utils/appUtils";
import { ExpenseDetailsSkeleton } from "./ExpenseSkeleton";
import { useHasUserPermission } from "../../hooks/useHasUserPermission";
import { REVIEW_EXPENSE } from "../../utils/constants";
import { PROCESS_EXPENSE, 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";
import Error from "../common/Error";
import DatePicker from "../common/DatePicker";
import { useEmployeeRoles, useEmployeesName } from "../../hooks/useEmployees";
import EmployeeSearchInput from "../common/EmployeeSearchInput";
import { z } from "zod";
import moment from "moment";
const ViewExpense = ({ ExpenseId }) => {
const { data, isLoading, isError, error } = useExpense(ExpenseId);
const [IsPaymentProcess, setIsPaymentProcess] = useState(false);
const [clickedStatusId, setClickedStatusId] = useState(null);
const IsReview = useHasUserPermission(REVIEW_EXPENSE);
const [imageLoaded, setImageLoaded] = useState({});
const { setDocumentView } = useExpenseContext();
const ActionSchema = ExpenseActionScheam(IsPaymentProcess) ?? z.object({});
const navigate = useNavigate();
const {
register,
handleSubmit,
setValue,
reset,
control,
formState: { errors },
} = useForm({
resolver: zodResolver(ActionSchema),
defaultValues: {
comment: "",
selectedStatus: "",
},
defaultValues: defaultActionValues,
});
const userPermissions = useSelector(
@ -51,171 +59,208 @@ const ViewExpense = ({ ExpenseId }) => {
: [];
if (permissionIds.length === 0) return true;
if (permissionIds.includes(PROCESS_EXPENSE)) {
setIsPaymentProcess(true);
}
return permissionIds.some((id) => userPermissions.includes(id));
});
}, [data, userPermissions]);
const { mutate: MakeAction } = useActionOnExpense(() => reset());
const { mutate: MakeAction,isPending } = useActionOnExpense(() => {
setClickedStatusId(null);
reset()});
const onSubmit = (formData) => {
const Payload = {
...formData,
reimburseDate:moment.utc(formData.reimburseDate, "DD-MM-YYYY").toISOString(),
expenseId: ExpenseId,
statusId: formData.selectedStatus,
comment: formData.comment,
};
MakeAction(Payload);
};
if (isLoading) return <ExpenseDetailsSkeleton />;
if (isError) return <Error error={error} />;
const handleImageLoad = (id) => {
setImageLoaded((prev) => ({ ...prev, [id]: true }));
};
return (
<form className="container px-3" onSubmit={handleSubmit(onSubmit)}>
<div className="row mb-3">
<div className="row mb-3">
<div className="col-12 mb-3">
<h5 className="fw-semibold">Expense Details</h5>
<hr />
</div>
{/* Each info block below now has h-100 to stretch */}
<div className="col-12 col-md-6 mb-3 h-100">
{/* Row 1 */}
<div className="col-md-6 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Transaction Date :
</label>
<div className="text-muted">
{formatUTCToLocalTime(data.transactionDate)}
{formatUTCToLocalTime(data?.transactionDate)}
</div>
</div>
</div>
<div className="col-12 col-md-6 mb-3 h-100">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Expense Type :
</label>
<div className="text-muted">{data.expensesType.name}</div>
<div className="text-muted">{data?.expensesType?.name}</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3 h-100">
{/* Row 2 */}
<div className="col-md-6 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Supplier :
</label>
<div className="text-muted">{data.supplerName}</div>
<div className="text-muted">{data?.supplerName}</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3 h-100">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Amount :</label>
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Amount :
</label>
<div className="text-muted"> {data.amount}</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3 h-100">
{/* Row 3 */}
<div className="col-md-6 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Payment Mode :
</label>
<div className="text-muted">{data.paymentMode.name}</div>
<div className="text-muted">{data?.paymentMode?.name}</div>
</div>
</div>
<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 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">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Paid By :
</label>
<div className="text-muted">
{data?.paidBy?.firstName} {data?.paidBy?.lastName}
</div>
</div>
</div>
{/* Row 4 */}
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Status :
</label>
<span
className={`badge bg-label-${
getColorNameFromHex(data?.status?.color) || "secondary"
}`}
>
{data?.status?.displayName}
</span>
</div>
</div>
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Pre-Approved :
</label>
<div className="text-muted">{data.preApproved ? "Yes" : "No"}</div>
</div>
</div>
<div className="col-12 col-md-6 mb-3 h-100">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Project :
</label>
<div className="text-muted text-start">{data?.project?.name}</div>
<div className="text-muted">{data?.project?.name}</div>
</div>
</div>
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Created At :
</label>
<div className="text-muted">
{formatUTCToLocalTime(data?.createdAt, true)}
</div>
</div>
</div>
<div className="row align-items-center mb-3">
<div className="col-12 col-md-auto">
{/* Row 6 */}
{data.createdBy && (
<div className="col-md-6 mb-3 text-start">
<div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">
Created At:
<label
className="form-label me-2 mb-0 fw-semibold"
style={{ minWidth: "130px" }}
>
Created By :
</label>
<div className="text-muted">
{formatUTCToLocalTime(data?.createdAt, true)}
<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>
{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>
)}
{data.reviewedBy && (
<div className="col-12 col-md-auto mb-3 h-100">
<div className="col-md-6 mb-3 text-start">
<div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">
<label
className="form-label me-2 mb-0 fw-semibold"
style={{ minWidth: "130px" }}
>
Reviewed By :
</label>
<div className="d-flex align-items-center">
<Avatar
size="xs"
@ -232,13 +277,16 @@ const ViewExpense = ({ ExpenseId }) => {
</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>
{data.approvedBy && (
<div className="col-md-6 mb-3 text-start">
<div className="d-flex align-items-center">
<label
className="form-label me-2 mb-0 fw-semibold"
style={{ minWidth: "130px" }}
>
Approved By :{" "}
</label>
<div className="d-flex align-items-center">
<Avatar
size="xs"
@ -255,14 +303,72 @@ const ViewExpense = ({ ExpenseId }) => {
</div>
</div>
)}
{data.processedBy && (
<div className="col-md-6 mb-3 text-start">
<div className="d-flex align-items-center">
<label
className="form-label me-2 mb-0 fw-semibold"
style={{ minWidth: "130px" }}
>
Processed By :{" "}
</label>
<div className="d-flex align-items-center">
<Avatar
size="xs"
classAvatar="m-0"
firstName={data.processedBy?.firstName}
lastName={data.processedBy?.lastName}
/>
<span className="text-muted">
{`${data.processedBy?.firstName ?? ""} ${
data.processedBy?.lastName ?? ""
}`.trim() || "N/A"}
</span>
</div>
</div>
</div>
)}
</div>
{data.expensesReimburse && (<div className="row text-start">
<div className="col-md-6 mb-3">
<strong>Transaction ID :</strong> {data.expensesReimburse.reimburseTransactionId || "N/A"}
</div>
<div className="col-md-6 mb-3">
<strong>Reimburse Date :</strong>{" "}
{ moment(data.expensesReimburse.reimburseDate).format("DD-MM-YYYY") }
</div>
{data.expensesReimburse && (
<>
<div className="col-md-6 mb-3 d-flex align-items-center">
<strong className="me-2">Reimburse By :</strong>
<Avatar
size="xs"
classAvatar="m-0 me-1"
firstName={data?.expensesReimburse?.reimburseBy?.firstName}
lastName={data?.expensesReimburse?.reimburseBy?.lastName}
/>
<span className="text-muted">
{`${data?.expensesReimburse?.reimburseBy?.firstName} ${data?.expensesReimburse?.reimburseBy?.lastName}`.trim()}
</span>
</div>
</>
)}
<div className="col-md-6 mb-3">
<strong>Note :</strong> {data.expensesReimburse.reimburseNote}
</div>
</div>)}
<div className="text-start">
<label className="form-label me-2 mb-0 fw-semibold">Description:</label>
<label className="form-label me-2 mb-0 fw-semibold">Description :</label>
<div className="text-muted">{data?.description}</div>
</div>
<div className="col-12 my-2 text-start">
<label className="form-label me-2 mb-0 fw-semibold">Attachment:</label>
<label className="form-label me-2 mb-0 fw-semibold">Attachment :</label>
{data?.documents?.map((doc) => {
const getIconByType = (type) => {
@ -284,7 +390,7 @@ const ViewExpense = ({ ExpenseId }) => {
return (
<div
className="list-group-item list-group-item-action d-flex align-items-center"
key={doc.id}
key={doc.documentId}
>
<div
className="rounded me-1 d-flex align-items-center justify-content-center cursor-pointer"
@ -325,43 +431,79 @@ const ViewExpense = ({ ExpenseId }) => {
<hr className="divider my-1" />
{Array.isArray(data?.nextStatus) && data.nextStatus.length > 0 && (
<div className="col-12 mb-3 text-start">
{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")} />
{nextStatusWithPermission?.length > 0 && (
<div className="text-center flex-wrap gap-2 my-2">
{nextStatusWithPermission?.map((status, index) => (
<button
key={status.id || index}
type="button"
onClick={() => {
setValue("selectedStatus", status.id);
handleSubmit(onSubmit)();
}}
className="btn btn-primary btn-sm cursor-pointer mx-2 border-0"
>
{status.displayName || status.name}
</button>
))}
<>
{IsPaymentProcess && (
<div className="row">
<div className="col-12 col-md-6 text-start">
<label className="form-label">Transaction Id </label>
<input
type="text"
className="form-control form-control-sm"
{...register("reimburseTransactionId")}
/>
{errors.reimburseTransactionId && (
<small className="danger-text">
{errors.reimburseTransactionId.message}
</small>
)}
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label">Transaction Date </label>
<DatePicker name="reimburseDate" control={control} />
{errors.reimburseDate && (
<small className="danger-text">
{errors.reimburseDate.message}
</small>
)}
</div>
<div className="col-12 col-md-6 text-start">
<label className="form-label">Employeee </label>
<EmployeeSearchInput
control={control}
name="reimburseById"
projectId={data?.project?.id}
/>
</div>
</div>
)}
</div>
<div className="col-12 mb-3 text-start">
{nextStatusWithPermission.length > 0 && (
<>
<label className="form-label me-2 mb-0 ">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 && (
<div className="text-center flex-wrap gap-2 my-2">
{nextStatusWithPermission?.map((status, index) => (
<button
key={status.id || index}
type="button"
onClick={() => {
setClickedStatusId(status.id);
setValue("statusId", status.id);
handleSubmit(onSubmit)();
}}
disabled={isPending}
className="btn btn-primary btn-sm cursor-pointer mx-2 border-0"
>
{(isPending && clickedStatusId === status.id) ? "Please Wait..." : (status.displayName || status.name)}
</button>
))}
</div>
)}
</div>
</>
)}
</form>
);

View File

@ -0,0 +1,85 @@
import { useState, useEffect } from "react";
import { useEmployeesName } from "../../hooks/useEmployees";
import { useDebounce } from "../../utils/appUtils";
import { useController } from "react-hook-form";
const EmployeeSearchInput = ({ control, name, projectId }) => {
const {
field: { onChange, value, ref },
fieldState: { error },
} = useController({ name, control });
const [search, setSearch] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const debouncedSearch = useDebounce(search, 500);
const {
data: employees,
isLoading,
} = useEmployeesName(projectId, debouncedSearch);
useEffect(() => {
if (value && !search) {
const found = employees?.data?.find((emp) => emp.id === value);
if (found) setSearch(found.firstName + " " + found.lastName);
}
}, [value, employees]);
const handleSelect = (employee) => {
onChange(employee.id);
setSearch(employee.firstName + " " + employee.lastName);
setShowDropdown(false);
};
return (
<div className="position-relative">
<input
type="text"
ref={ref}
className={`form-control form-control-sm`}
placeholder="Search employee..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setShowDropdown(true);
onChange(""); // Clear previous selection
}}
onFocus={() => {
if (search) setShowDropdown(true);
}}
/>
{showDropdown && (employees?.data?.length > 0 || isLoading) && (
<ul
className="list-group position-absolute bg-white w-100 shadow z-3 rounded-none"
style={{ maxHeight: 200, overflowY: "auto" }}
>
{isLoading ? (
<li className="list-group-item">
<a>Searching...</a>
</li>
) : (
employees?.data?.map((emp) => (
<li
key={emp.id}
className="list-group-item list-group-item-action"
style={{ cursor: "pointer" }}
onClick={() => handleSelect(emp)}
>
{emp.firstName} {emp.lastName}
</li>
))
)}
</ul>
)}
{error && <small className="danger-text">{error.message}</small>}
</div>
);
};
export default EmployeeSearchInput;

View File

@ -0,0 +1,20 @@
import React from 'react'
const Error = ({error,close}) => {
console.log(error)
return (
<div className="container text-center py-5">
<h1 className="display-4 fw-bold text-danger">{error.statusCode || error?.response?.status
}</h1>
<h2 className="mb-3">Internal Server Error</h2>
<p className="lead">
{error.message}
</p>
<a href="/" className="btn btn-primary btn-sm mt-3" onClick={()=>close()}>
Go to Home
</a>
</div>
)
}
export default Error

View File

@ -3,27 +3,22 @@ import { cacheData, getCachedData } from "../slices/apiDataManager";
import { RolesRepository } from "../repositories/MastersRepository";
import EmployeeRepository from "../repositories/EmployeeRepository";
import ProjectRepository from "../repositories/ProjectRepository";
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import showToast from "../services/toastService";
import {useSelector} from "react-redux";
import {store} from "../store/store";
import {queryClient} from "../layouts/AuthLayout";
import { useSelector } from "react-redux";
import { store } from "../store/store";
import { queryClient } from "../layouts/AuthLayout";
// Query ---------------------------------------------------------------------------
export const useAllEmployees = ( showInactive ) =>
{
export const useAllEmployees = (showInactive) => {
const {
data = [],
isLoading,
error,
refetch, // optional if you want recall functionality
} = useQuery({
queryKey: ['allEmployee', showInactive],
queryKey: ["allEmployee", showInactive],
queryFn: async () => {
const res = await EmployeeRepository.getAllEmployeeList(showInactive);
return res.data;
@ -34,14 +29,12 @@ export const useAllEmployees = ( showInactive ) =>
employeesList: data,
loading: isLoading,
error,
recallEmployeeData: refetch,
recallEmployeeData: refetch,
};
};
// ManageBucket.jsx
export const useEmployees = ( selectedProject ) =>
{
export const useEmployees = (selectedProject) => {
const {
data = [],
isLoading,
@ -50,7 +43,9 @@ export const useEmployees = ( selectedProject ) =>
} = useQuery({
queryKey: ["employeeListByProject", selectedProject],
queryFn: async () => {
const res = await EmployeeRepository.getEmployeeListByproject(selectedProject);
const res = await EmployeeRepository.getEmployeeListByproject(
selectedProject
);
return res.data || res;
},
enabled: !!selectedProject,
@ -72,12 +67,12 @@ export const useEmployeeRoles = (employeeId) => {
isLoading: loading,
error,
} = useQuery({
queryKey: ['employeeRoles', employeeId],
queryKey: ["employeeRoles", employeeId],
queryFn: async () => {
const res = await RolesRepository.getEmployeeRoles(employeeId);
return res.data;
},
enabled: !!employeeId,
enabled: !!employeeId,
});
return {
@ -95,12 +90,12 @@ export const useEmployeesByProject = (projectId) => {
error,
refetch: recallProjectEmplloyee,
} = useQuery({
queryKey: ['projectEmployees', projectId],
queryKey: ["projectEmployees", projectId],
queryFn: async () => {
const res = await ProjectRepository.getEmployeesByProject(projectId);
return res.data;
},
enabled: !!projectId,
enabled: !!projectId,
});
return {
@ -112,16 +107,17 @@ export const useEmployeesByProject = (projectId) => {
};
// EmployeeList.jsx
export const useEmployeesAllOrByProjectId = (showAllEmployees ,projectId,
showInactive) => {
const queryKey = showAllEmployees
? ['allEmployees', showInactive]
: ['projectEmployees', projectId, showInactive];
export const useEmployeesAllOrByProjectId = (
showAllEmployees,
projectId,
showInactive
) => {
const queryKey = showAllEmployees
? ["allEmployees", showInactive]
: ["projectEmployees", projectId, showInactive];
const queryFn = async () => {
if (showAllEmployees) {
if (showAllEmployees) {
const res = await EmployeeRepository.getAllEmployeeList(showInactive);
return res.data;
} else {
@ -139,7 +135,8 @@ export const useEmployeesAllOrByProjectId = (showAllEmployees ,projectId,
} = useQuery({
queryKey,
queryFn,
enabled:typeof showInactive === "boolean" && (showAllEmployees || !!projectId),
enabled:
typeof showInactive === "boolean" && (showAllEmployees || !!projectId),
});
return {
@ -151,69 +148,85 @@ export const useEmployeesAllOrByProjectId = (showAllEmployees ,projectId,
};
// ManageEmployee.jsx
export const useEmployeeProfile = ( employeeId ) =>
{
export const useEmployeeProfile = (employeeId) => {
const isEnabled = !!employeeId;
const {
data = null,
isLoading: loading,
error,
refetch
refetch,
} = useQuery({
queryKey: ['employeeProfile', employeeId],
queryKey: ["employeeProfile", employeeId],
queryFn: async () => {
if (!employeeId) return null;
const res = await EmployeeRepository.getEmployeeProfile(employeeId);
return res.data;
},
enabled: isEnabled,
enabled: isEnabled,
});
return {
employee: data,
loading,
error,
refetch
refetch,
};
};
export const useEmployeesName = (projectId, search) => {
return useQuery({
queryKey: ["employees", projectId, search],
queryFn: async() => await EmployeeRepository.getEmployeeName(projectId, search),
staleTime: 5 * 60 * 1000, // Optional: cache for 5 minutes
});
};
// Mutation------------------------------------------------------------------
export const useUpdateEmployee = () =>
{
const selectedProject = useSelector((store)=>store.localVariables.projectId)
const queryClient = useQueryClient();
export const useUpdateEmployee = () => {
const selectedProject = useSelector(
(store) => store.localVariables.projectId
);
const queryClient = useQueryClient();
return useMutation({
mutationFn: (employeeData) => EmployeeRepository.manageEmployee(employeeData),
mutationFn: (employeeData) =>
EmployeeRepository.manageEmployee(employeeData),
onSuccess: (_, variables) => {
const id = variables.id || variables.employeeId;
const isAllEmployee = variables.IsAllEmployee;
// Cache invalidation
queryClient.invalidateQueries( {queryKey:[ 'allEmployees'] });
queryClient.invalidateQueries({ queryKey: ["allEmployees"] });
// queryClient.invalidateQueries(['employeeProfile', id]);
queryClient.invalidateQueries( {queryKey: [ 'projectEmployees' ]} );
queryClient.removeQueries( {queryKey: [ "empListByProjectAllocated" ]} );
queryClient.invalidateQueries({ queryKey: ["projectEmployees"] });
queryClient.removeQueries({ queryKey: ["empListByProjectAllocated"] });
// queryClient.invalidateQueries( {queryKey:[ 'employeeListByProject']} );
showToast( `Employee ${ id ? 'updated' : 'created' } successfully`, 'success' );
showToast(
`Employee ${id ? "updated" : "created"} successfully`,
"success"
);
},
onError: (error) => {
const msg = error?.response?.data?.message || error.message || 'Something went wrong';
showToast(msg, 'error');
const msg =
error?.response?.data?.message ||
error.message ||
"Something went wrong";
showToast(msg, "error");
},
});
};
export const useSuspendEmployee = ({ setIsDeleteModalOpen, setemployeeLodaing }) => {
export const useSuspendEmployee = ({
setIsDeleteModalOpen,
setemployeeLodaing,
}) => {
const queryClient = useQueryClient();
const selectedProject = useSelector((store)=>store.localVariables.projectId)
const selectedProject = useSelector(
(store) => store.localVariables.projectId
);
return useMutation({
mutationFn: (id) => {
setemployeeLodaing(true);
@ -221,12 +234,12 @@ export const useSuspendEmployee = ({ setIsDeleteModalOpen, setemployeeLodaing })
},
onSuccess: () => {
// queryClient.invalidateQueries( ['allEmployee',false]);
queryClient.invalidateQueries( {queryKey: [ 'projectEmployees' ]} );
queryClient.invalidateQueries( {queryKey:[ 'employeeListByProject' ,selectedProject]} );
showToast("Employee deleted successfully.", "success");
queryClient.invalidateQueries({ queryKey: ["projectEmployees"] });
queryClient.invalidateQueries({
queryKey: ["employeeListByProject", selectedProject],
});
showToast("Employee deleted successfully.", "success");
setIsDeleteModalOpen(false);
},
@ -247,8 +260,11 @@ export const useSuspendEmployee = ({ setIsDeleteModalOpen, setemployeeLodaing })
// Manage Role
export const useUpdateEmployeeRoles = ({ onClose, resetForm, onSuccessCallback } = {}) => {
export const useUpdateEmployeeRoles = ({
onClose,
resetForm,
onSuccessCallback,
} = {}) => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (updates) => RolesRepository.createEmployeeRoles(updates),
@ -259,12 +275,14 @@ export const useUpdateEmployeeRoles = ({ onClose, resetForm, onSuccessCallback }
onClose?.();
onSuccessCallback?.();
queryClient.invalidateQueries( {queryKey: [ "employeeRoles" ]} );
queryClient.invalidateQueries( {queryKey: [ "profile" ]} );
queryClient.invalidateQueries({ queryKey: ["employeeRoles"] });
queryClient.invalidateQueries({ queryKey: ["profile"] });
},
onError: (err) => {
const message =
err?.response?.data?.message || err?.message || "Error occurred while updating roles";
err?.response?.data?.message ||
err?.message ||
"Error occurred while updating roles";
showToast(message, "error");
},
});

View File

@ -20,10 +20,14 @@ const cleanFilter = (filter) => {
return cleaned;
};
export const useExpenseList = (pageSize, pageNumber, filter, searchString = '') => {
export const useExpenseList = (
pageSize,
pageNumber,
filter,
searchString = ""
) => {
return useQuery({
queryKey: ["Expenses", pageNumber, pageSize, filter, searchString],
queryKey: ["Expenses", pageNumber, pageSize, filter, searchString],
queryFn: async () => {
const cleanedFilter = cleanFilter(filter);
const response = await ExpenseRepository.GetExpenseList(
@ -38,8 +42,6 @@ export const useExpenseList = (pageSize, pageNumber, filter, searchString = '')
});
};
export const useExpense = (ExpenseId) => {
return useQuery({
queryKey: ["Expense", ExpenseId],
@ -55,13 +57,10 @@ export const useExpenseFilter = () => {
return useQuery({
queryKey: ["ExpenseFilter"],
queryFn: async () =>
await ExpenseRepository.GetExpenseFilter().then(
(res) => res.data
),
await ExpenseRepository.GetExpenseFilter().then((res) => res.data),
});
};
// ---------------------------Mutation---------------------------------------------
export const useCreateExpnse = (onSuccessCallBack) => {
@ -143,9 +142,9 @@ export const useUpdateExpense = (onSuccessCallBack) => {
if (onSuccessCallBack) onSuccessCallBack();
},
onError:(error)=>{
showToast( "Something went wrong.Please try again later.", "error");
}
onError: (error) => {
showToast("Something went wrong.Please try again later.", "error");
},
});
};
@ -181,22 +180,24 @@ export const useActionOnExpense = (onSuccessCallBack) => {
};
}
);
queryClient.setQueriesData(
{ queryKey: ["Expense", updatedExpense.id] },
(oldData) => {
return {
...oldData,
nextStatus: updatedExpense.nextStatus,
status: updatedExpense.status,
};
}
);
// queryClient.setQueriesData(
// { queryKey: ["Expense", updatedExpense.id] },
// (oldData) => {
// return {
// ...oldData,
// nextStatus: updatedExpense.nextStatus,
// status: updatedExpense.status,
// };
// }
// );
queryClient.invalidateQueries({queryKey:["Expense",updatedExpense.id]})
if (onSuccessCallBack) onSuccessCallBack();
},
onError: (error) => {
showToast(
error.message || "Something went wrong.Please try again later.",
error.response.data.message ||
"Something went wrong.Please try again later.",
"error"
);
},
@ -213,7 +214,8 @@ export const useDeleteExpense = () => {
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["Expenses"], (oldData) => {
if (!oldData || !oldData.data) return queryClient.invalidateQueries({queryKey:["Expenses"]});
if (!oldData || !oldData.data)
return queryClient.invalidateQueries({ queryKey: ["Expenses"] });
const updatedList = oldData.data.filter(
(expense) => expense.id !== variables.id
@ -228,18 +230,18 @@ export const useDeleteExpense = () => {
showToast(data.message || "Expense deleted successfully", "success");
},
onError: (error) => {
showToast(error.message || error.response.message || "Something went wrong.Please try again later.", "error");
showToast(
error.message ||
error.response.message ||
"Something went wrong.Please try again later.",
"error"
);
},
});
}
};
export const useHasAnyPermission = (permissionIdsInput) => {
const permissions = useSelector(
(state) => state?.profile?.permissions || []
);
const permissions = useSelector((state) => state?.profile?.permissions || []);
const permissionIds = Array.isArray(permissionIdsInput)
? permissionIdsInput
@ -249,4 +251,4 @@ export const useHasAnyPermission = (permissionIdsInput) => {
if (permissionIds.length === 0) return true;
return permissionIds.some((id) => permissions.includes(id));
};
};

View File

@ -1,46 +1,33 @@
import React, {
createContext,
useContext,
useState,
useRef,
useEffect,
} from "react";
import {
useForm,
useFieldArray,
FormProvider,
useFormContext,
Controller,
} from "react-hook-form";
import React, { createContext, useContext, useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSelector } from "react-redux";
// Components
import ExpenseList from "../../components/Expenses/ExpenseList";
import ViewExpense from "../../components/Expenses/ViewExpense";
import Breadcrumb from "../../components/common/Breadcrumb";
import GlobalModel from "../../components/common/GlobalModel";
import PreviewDocument from "../../components/Expenses/PreviewDocument";
import ManageExpense from "../../components/Expenses/ManageExpense";
import { useProjectName } from "../../hooks/useProjects";
import { useExpenseStatus } from "../../hooks/masterHook/useMaster";
import {
useEmployees,
useEmployeesAllOrByProjectId,
} from "../../hooks/useEmployees";
import { useSelector } from "react-redux";
import DateRangePicker from "../../components/common/DateRangePicker";
import SelectMultiple from "../../components/common/SelectMultiple";
import { zodResolver } from "@hookform/resolvers/zod";
import {
defaultFilter,
SearchSchema,
} from "../../components/Expenses/ExpenseSchema";
import ExpenseFilterPanel from "../../components/Expenses/ExpenseFilterPanel";
// Context & Hooks
import { useFab } from "../../Context/FabContext";
import { useHasUserPermission } from "../../hooks/useHasUserPermission";
import {
CREATE_EXEPENSE,
VIEW_ALL_EXPNESE,
VIEW_SELF_EXPENSE,
} from "../../utils/constants";
import { useFab } from "../../Context/FabContext";
import ExpenseFilterPanel from "../../components/Expenses/ExpenseFilterPanel";
// Schema & Defaults
import {
defaultFilter,
SearchSchema,
} from "../../components/Expenses/ExpenseSchema";
// Context
export const ExpenseContext = createContext();
export const useExpenseContext = () => {
const context = useContext(ExpenseContext);
@ -51,114 +38,101 @@ 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
);
const [filters, setFilter] = useState();
const [groupBy, setGroupBy] = useState("transactionDate");
const [searchText, setSearchText] = useState("");
const [ManageExpenseModal, setManageExpenseModal] = useState({
IsOpen: null,
expenseId: null,
});
const [viewExpense, setViewExpense] = useState({
expenseId: null,
view: false,
});
const [ViewDocument, setDocumentView] = useState({
IsOpen: false,
Image: null,
});
const IsCreatedAble = useHasUserPermission(CREATE_EXEPENSE);
const IsViewAll = useHasUserPermission(VIEW_ALL_EXPNESE);
const IsViewSelf = useHasUserPermission(VIEW_SELF_EXPENSE);
const contextValue = {
setViewExpense,
setManageExpenseModal,
setDocumentView,
};
const { setOffcanvasContent, setShowTrigger } = useFab();
const methods = useForm({
resolver: zodResolver(SearchSchema),
defaultValues: defaultFilter,
});
const { setOffcanvasContent, setShowTrigger } = useFab();
const { reset } = methods;
const clearFilter = () => {
setFilter({
projectIds: [],
statusIds: [],
createdByIds: [],
paidById: [],
startDate: null,
endDate: null,
});
setFilter(defaultFilter);
reset();
};
useEffect(() => {
setShowTrigger(true);
setOffcanvasContent(
"Expense Filters",
<ExpenseFilterPanel
onApply={(data) => {
setFilter(data);
}}
handleGroupBy={(groupId) => setGropBy(groupId)}
onApply={setFilter}
handleGroupBy={setGroupBy}
clearFilter={clearFilter}
/>
);
return () => {
setOffcanvasContent("", null);
setShowTrigger(false);
setOffcanvasContent("", null);
};
}, []);
const contextValue = {
setViewExpense,
setManageExpenseModal,
setDocumentView,
};
return (
<ExpenseContext.Provider value={contextValue}>
<div className="container-fluid">
<Breadcrumb
data={[
{ label: "Home", link: "/" },
{ label: "Expense", link: null },
]}
/>
{IsViewAll || IsViewSelf ? (
<Breadcrumb data={[{ label: "Home", link: "/" }, { label: "Expense" }]} />
{(IsViewAll || IsViewSelf) ? (
<>
<div className="card my-1 px-0">
<div className="card my-1">
<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>
<div className="col-sm-6 col-md-4">
<div className="d-flex align-items-center">
<span className="form-label me-2" id="search-label">Search</span>
<input
type="search"
className="form-control"
className="form-control form-control-sm w-auto"
placeholder="Search Expense"
aria-label="Search"
aria-describedby="search-label"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
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">
<div className="col-sm-6 col-md-8 text-end mt-2 mt-sm-0">
{IsCreatedAble && (
<button
type="button"
title="Add New Expense"
className="p-1 me-2 bg-primary rounded-circle"
onClick={() =>
setManageExpenseModal({
IsOpen: true,
expenseId: null,
})
}
title="Add New Expense"
onClick={() => setManageExpenseModal({ IsOpen: true, expenseId: null })}
>
<i className="bx bx-plus fs-4 text-white"></i>
</button>
@ -168,49 +142,36 @@ const ExpensePage = () => {
</div>
</div>
<ExpenseList filters={filters} groupBy={groupBy} searchText={searchText}/>
<ExpenseList filters={filters} groupBy={groupBy} searchText={searchText} />
</>
) : (
<div className="card text-center py-1">
<i className="fa-solid fa-triangle-exclamation fs-5"></i>
<p>
Access Denied: You don't have permission to perform this action. !
</p>
<i className="fa-solid fa-triangle-exclamation fs-5" />
<p>Access Denied: You don't have permission to perform this action!</p>
</div>
)}
{/* Modals */}
{ManageExpenseModal.IsOpen && (
<GlobalModel
isOpen={ManageExpenseModal.IsOpen}
isOpen
size="lg"
closeModal={() =>
setManageExpenseModal({
IsOpen: null,
expenseId: null,
})
}
closeModal={() => setManageExpenseModal({ IsOpen: null, expenseId: null })}
>
<ManageExpense
key={ManageExpenseModal.expenseId ?? "new"}
expenseToEdit={ManageExpenseModal.expenseId}
closeModal={() =>
setManageExpenseModal({ IsOpen: null, expenseId: null })
}
closeModal={() => setManageExpenseModal({ IsOpen: null, expenseId: null })}
/>
</GlobalModel>
)}
{viewExpense.view && (
<GlobalModel
isOpen={viewExpense.view}
isOpen
size="lg"
modalType="top"
closeModal={() =>
setViewExpense({
expenseId: null,
view: false,
})
}
closeModal={() => setViewExpense({ expenseId: null, view: false })}
>
<ViewExpense ExpenseId={viewExpense.expenseId} />
</GlobalModel>
@ -218,9 +179,9 @@ const ExpensePage = () => {
{ViewDocument.IsOpen && (
<GlobalModel
isOpen
size="lg"
key={ViewDocument.IsOpen ?? "new"}
isOpen={ViewDocument.IsOpen}
key={ViewDocument.Image ?? "doc"}
closeModal={() => setDocumentView({ IsOpen: false, Image: null })}
>
<PreviewDocument imageUrl={ViewDocument.Image} />

View File

@ -1,17 +1,22 @@
import { api } from "../utils/axiosClient";
const EmployeeRepository = {
getAllEmployeeList:(showInactive)=>api.get(`api/employee/list?showInactive=${showInactive}`),
getAllEmployeeList: (showInactive) =>
api.get(`api/employee/list?showInactive=${showInactive}`),
getEmployeeListByproject: (projectid) =>
api.get(`/api/employee/list/${projectid}`),
searchEmployees: (query) =>
api.get(`/api/employee/search/${query}`),
manageEmployee: (data) =>
api.post("/api/employee/manage", data),
searchEmployees: (query) => api.get(`/api/employee/search/${query}`),
manageEmployee: (data) => api.post("/api/employee/manage", data),
updateEmployee: (id, data) => api.put(`/users/${id}`, data),
// deleteEmployee: ( id ) => api.delete( `/users/${ id }` ),
getEmployeeProfile:(id)=>api.get(`/api/employee/profile/get/${id}`),
deleteEmployee:(id)=>api.delete(`/api/employee/${id}`)
getEmployeeProfile: (id) => api.get(`/api/employee/profile/get/${id}`),
deleteEmployee: (id) => api.delete(`/api/employee/${id}`),
getEmployeeName: (projectId, search) =>
api.get(
`/api/Employee/basic${projectId ? `?projectId=${projectId}` : ""}${
search ? `${projectId ? "&" : "?"}searchString=${search}` : ""
}`
),
};
export default EmployeeRepository;

View File

@ -44,7 +44,7 @@ axiosClient.interceptors.response.use(
const originalRequest = error.config;
// Skip retry for public requests or already retried ones
if (!originalRequest || originalRequest._retry || originalRequest.authRequired === false) {
if (!originalRequest && originalRequest._retry || originalRequest.authRequired === false) {
return Promise.reject(error);
}