diff --git a/public/assets/vendor/css/core.css b/public/assets/vendor/css/core.css index a9930ceb..85124986 100644 --- a/public/assets/vendor/css/core.css +++ b/public/assets/vendor/css/core.css @@ -20473,7 +20473,10 @@ li:not(:first-child) .dropdown-item, word-wrap: break-word !important; word-break: break-word !important; } - +/* text-size */ +.text-tiny{ + font-size: 13px; +} /* rtl:end:remove */ .text-primary { --bs-text-opacity: 1; diff --git a/src/components/Expenses/ExpenseFilterPanel.jsx b/src/components/Expenses/ExpenseFilterPanel.jsx new file mode 100644 index 00000000..44a03780 --- /dev/null +++ b/src/components/Expenses/ExpenseFilterPanel.jsx @@ -0,0 +1,197 @@ +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, { 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, handleGroupBy }) => { + const selectedProjectId = useSelector((store) => store.localVariables.projectId); + const { data, isLoading,isError,error,isFetching , isFetched} = 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, register, handleSubmit, reset, watch } = methods; + const isTransactionDate = watch("isTransactionDate"); + + const closePanel = () => { + document.querySelector(".offcanvas.show .btn-close")?.click(); + }; + + 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 || isFetching) return ; + if(isError && isFetched) return
Something went wrong Here- {error.message}
+ return ( + <> + + +
+
+
+ +
+ +
+ +
+ + +
+ +
+ + item.name} + valueKey="id" + /> + item.name} + valueKey="id" + /> + +
+ +
+ {data?.status + ?.slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map((status) => ( +
+ ( +
+ { + const checked = e.target.checked; + onChange( + checked + ? [...value, status.id] + : value.filter((v) => v !== status.id) + ); + }} + /> + +
+ )} + /> +
+ ))} +
+
+
+
+ + +
+ +
+ + +
+
+
+ + + ); +}; + +export default ExpenseFilterPanel; \ No newline at end of file diff --git a/src/components/Expenses/ExpenseList.jsx b/src/components/Expenses/ExpenseList.jsx new file mode 100644 index 00000000..cb9567df --- /dev/null +++ b/src/components/Expenses/ExpenseList.jsx @@ -0,0 +1,321 @@ +import React, { useState } from "react"; +import { useDeleteExpense, useExpenseList } from "../../hooks/useExpense"; +import Avatar from "../common/Avatar"; +import { useExpenseContext } from "../../pages/Expense/ExpensePage"; +import { formatDate, formatUTCToLocalTime } from "../../utils/dateUtils"; +import Pagination from "../common/Pagination"; +import { APPROVE_EXPENSE, EXPENSE_DRAFT, EXPENSE_REJECTEDBY } from "../../utils/constants"; +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",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 = 20; + const debouncedSearch = useDebounce(searchText, 500); + + const { mutate: DeleteExpense, isPending } = useDeleteExpense(); + const { data, isLoading, isError, isInitialLoading, error } = useExpenseList( + pageSize, + currentPage, + filters, + debouncedSearch + ); + + const SelfId = useSelector( + (store) => store?.globalVariables?.loginUser?.employeeInfo?.id + ); + + const handleDelete = (id) => { + setDeletingId(id); + DeleteExpense( + { id }, + { + onSettled: () => { + setDeletingId(null); + setIsDeleteModalOpen(false); + }, + } + ); + }; + + const paginate = (page) => { + if (page >= 1 && page <= (data?.totalPages ?? 1)) { + setCurrentPage(page); + } + }; + + const groupByField = (items, field) => { + return items.reduce((acc, item) => { + let key; + switch (field) { + case "transactionDate": + key = item.transactionDate?.split("T")[0]; + break; + case "status": + key = item.status?.displayName || "Unknown"; + break; + case "paidBy": + key = `${item.paidBy?.firstName ?? ""} ${ + item.paidBy?.lastName ?? "" + }`.trim(); + break; + case "project": + key = item.project?.name || "Unknown Project"; + break; + case "paymentMode": + key = item.paymentMode?.name || "Unknown Mode"; + break; + case "expensesType": + key = item.expensesType?.name || "Unknown Type"; + break; + case "createdAt": + key = item.createdAt?.split("T")[0] || "Unknown Type"; + break; + default: + key = "Others"; + } + if (!acc[key]) acc[key] = []; + acc[key].push(item); + return acc; + }, {}); + }; + + const expenseColumns = [ + { + key: "expensesType", + label: "Expense Type", + getValue: (e) => e.expensesType?.name || "N/A", + align: "text-start", + }, + { + key: "paymentMode", + label: "Payment Mode", + getValue: (e) => e.paymentMode?.name || "N/A", + align: "text-start", + }, + { + key: "paidBy", + label: "Paid By", + align: "text-start", + getValue: (e) => + `${e.paidBy?.firstName ?? ""} ${e.paidBy?.lastName ?? ""}`.trim() || + "N/A", + customRender: (e) => ( +
+ + + {`${e.paidBy?.firstName ?? ""} ${ + e.paidBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+ ), + }, + { + key: "submitted", + label: "Submitted", + getValue: (e) => formatUTCToLocalTime(e?.createdAt), + isAlwaysVisible: true, + }, + { + key: "amount", + label: "Amount", + getValue: (e) => ( + <> + {e?.amount} + + ), + isAlwaysVisible: true, + align: "text-end", + }, + { + key: "status", + label: "Status", + align: "text-center", + getValue: (e) => ( + + {e.status?.name || "Unknown"} + + ), + }, + ]; + + if (isInitialLoading) return ; + if (isError) return
{error}
; + + const grouped = groupBy + ? groupByField(data?.data ?? [], groupBy) + : { All: data?.data ?? [] }; + const IsGroupedByDate = ["transactionDate", "createdAt"].includes(groupBy); +const canEditExpense = (expense) => { + return ( + (expense.status.id === EXPENSE_DRAFT || + EXPENSE_REJECTEDBY.includes(expense.status.id)) && + expense.createdBy?.id === SelfId + ); +}; + +const canDetetExpense = (expense)=>{ + return (expense.status.id === EXPENSE_DRAFT && expense.createdBy.id === SelfId ) +} + + + return ( + <> + {IsDeleteModalOpen && ( +
+ setIsDeleteModalOpen(false)} + loading={isPending} + paramData={deletingId} + /> +
+ )} + +
+
+
+ + + + {expenseColumns.map( + (col) => + (col.isAlwaysVisible || groupBy !== col.key) && ( + + ) + )} + + + + + {Object.keys(grouped).length > 0 ? ( + Object.entries(grouped).map(([group, expenses]) => ( + + + + + {expenses.map((expense) => ( + + {expenseColumns.map( + (col) => + (col.isAlwaysVisible || groupBy !== col.key) && ( + + ) + )} + + + ))} + + )) + ) : ( + + + + )} + +
+
{col.label}
+
+ Action +
+ + {IsGroupedByDate + ? formatUTCToLocalTime(group) + : group} + +
+ {col.customRender + ? col.customRender(expense) + : col.getValue(expense)} + +
+ + setViewExpense({ + expenseId: expense.id, + view: true, + }) + } + > + {canEditExpense(expense) && ( + + setManageExpenseModal({ + IsOpen: true, + expenseId: expense.id, + }) + } + > +)} + + {canDetetExpense(expense) && ( + { + setIsDeleteModalOpen(true); + setDeletingId(expense.id); + }} + > + )} + +
+
+ No Expense Found +
+ {data?.data?.length > 0 && ( + + )} +
+
+
+ + ); +}; + +export default ExpenseList; diff --git a/src/components/Expenses/ExpenseSchema.js b/src/components/Expenses/ExpenseSchema.js new file mode 100644 index 00000000..f1e6320b --- /dev/null +++ b/src/components/Expenses/ExpenseSchema.js @@ -0,0 +1,164 @@ +import { z } from "zod"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ALLOWED_TYPES = [ + "application/pdf", + "image/png", + "image/jpg", + "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" }), + 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" }) + , + 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", + }) + .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(), + 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(), + 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), + }) + ) + .nonempty({ message: "At least one file attachment is required" }), + + + }) + .refine( + (data) => { + return ( + !data.projectId || (data.paidById && data.paidById.trim() !== "") + ); + }, + { + message: "Please select who paid (employee)", + path: ["paidById"], + } + ) + .superRefine((data, ctx) => { + const expenseType = expenseTypes.find((et) => et.id === data.expensesTypeId); + if (expenseType?.noOfPersonsRequired && (!data.noOfPersons || data.noOfPersons < 1)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "No. of Persons is required and must be at least 1", + path: ["noOfPersons"], + }); + } + }); +}; + +export const defaultExpense = { + projectId: "", + expensesTypeId: "", + paymentModeId: "", + paidById: "", + transactionDate: "", + transactionId: "", + description: "", + location: "", + supplerName: "", + amount: "", + noOfPersons: "", + billAttachments: [], +}; + + +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(), + 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(), + isTransactionDate: z.boolean().default(true), +}); + +export const defaultFilter = { + projectIds: [], + statusIds: [], + createdByIds: [], + paidById: [], + isTransactionDate: true, + startDate: null, + endDate: null, +}; + diff --git a/src/components/Expenses/ExpenseSkeleton.jsx b/src/components/Expenses/ExpenseSkeleton.jsx new file mode 100644 index 00000000..8f73db5c --- /dev/null +++ b/src/components/Expenses/ExpenseSkeleton.jsx @@ -0,0 +1,283 @@ +import React from "react"; + + +const SkeletonLine = ({ height = 20, width = "100%", className = "" }) => ( +
+); + + +const ExpenseSkeleton = () => { + return ( +
+
+ +
+ + {[...Array(5)].map((_, idx) => ( +
+
+ +
+
+ +
+
+ ))} + +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+ ); +}; + +export default ExpenseSkeleton; + + + + +export const ExpenseDetailsSkeleton = () => { + return ( +
+
+
+ +
+ + {[...Array(3)].map((_, i) => ( +
+ + +
+ ))} + + {[...Array(6)].map((_, i) => ( +
+ + +
+ ))} + + + +
+ + {[...Array(2)].map((_, i) => ( +
+
+
+ + +
+
+ ))} +
+ +
+ +
+ + +
+ {[...Array(2)].map((_, i) => ( + + ))} +
+
+
+
+ ); +}; +const SkeletonCell = ({ width = "100%", height = 20, className = "", style = {} }) => ( +
+); + +export const ExpenseTableSkeleton = ({ groups = 3, rowsPerGroup = 3 }) => { + return ( +
+ + + + + + + + + + + + + + {[...Array(groups)].map((_, groupIdx) => ( + + {/* Fake Date Group Header Row */} + + + + + {/* Rows under this group */} + {[...Array(rowsPerGroup)].map((__, rowIdx) => ( + + {/* Expense Type */} + + + {/* Payment Mode */} + + + {/* Paid By (Avatar + name) */} + + + {/* Amount */} + + + {/* Status */} + + + {/* Action */} + + + ))} + + ))} + +
+
Expense Type
+
+
Payment Mode
+
Paid ByAmountStatusAction
+ +
+ + + + +
+ + +
+
+ + + + +
+ {[...Array(3)].map((__, i) => ( + + ))} +
+
+
+ ); +}; + + +export const ExpenseFilterSkeleton = () => { + return ( +
+ {/* Created Date Label and Skeleton */} +
+ + +
+ +
+ {/* Project Select */} +
+ + +
+ + {/* Submitted By Select */} +
+ + +
+ + {/* Paid By Select */} +
+ + +
+ + {/* Status Checkboxes */} +
+ +
+ {[...Array(3)].map((_, i) => ( +
+
+ +
+ ))} +
+
+
+ + {/* Buttons */} +
+ + +
+
+ ); +}; diff --git a/src/components/Expenses/ExpenseStatusLogs.jsx b/src/components/Expenses/ExpenseStatusLogs.jsx new file mode 100644 index 00000000..879cbf56 --- /dev/null +++ b/src/components/Expenses/ExpenseStatusLogs.jsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import Avatar from "../common/Avatar"; +import { formatUTCToLocalTime } from "../../utils/dateUtils"; + +const ExpenseStatusLogs = ({ data }) => { + const [visibleCount, setVisibleCount] = useState(4); + + const logsToShow = data?.expenseLogs?.slice(0, visibleCount) || []; + + const handleShowMore = () => { + setVisibleCount((prev) => prev + 4); + }; + + return ( + <> +
+ {logsToShow.map((log, index) => ( +
+ + +
+
+
+
+
+ {`${log.updatedBy.firstName} ${log.updatedBy.lastName}`} + + {log.action} + + {formatUTCToLocalTime(log?.updateAt)} +
+
+
+ {log.comment} +
+
+
+
+
+ ))} +
+ + {data?.expenseLogs?.length > visibleCount && ( +
+ +
+ )} + + ); +}; + +export default ExpenseStatusLogs; diff --git a/src/components/Expenses/ManageExpense.jsx b/src/components/Expenses/ManageExpense.jsx new file mode 100644 index 00000000..c6d11e85 --- /dev/null +++ b/src/components/Expenses/ManageExpense.jsx @@ -0,0 +1,560 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { defaultExpense, ExpenseSchema } from "./ExpenseSchema"; +import { formatFileSize } from "../../utils/appUtils"; +import { useProjectName } from "../../hooks/useProjects"; +import { useDispatch, useSelector } from "react-redux"; +import { changeMaster } from "../../slices/localVariablesSlice"; +import useMaster, { + useExpenseStatus, + useExpenseType, + usePaymentMode, +} from "../../hooks/masterHook/useMaster"; +import { + useEmployeesAllOrByProjectId, + useEmployeesByProject, + useEmployeesName, + useEmployeesNameByProject, +} from "../../hooks/useEmployees"; +import Avatar from "../common/Avatar"; +import { + useCreateExpnse, + useExpense, + useUpdateExpense, +} from "../../hooks/useExpense"; +import ExpenseSkeleton from "./ExpenseSkeleton"; +import moment from "moment"; +import DatePicker from "../common/DatePicker"; +import ErrorPage from "../../pages/ErrorPage"; + +const ManageExpense = ({ closeModal, expenseToEdit = null }) => { + const { + data, + isLoading, + error: ExpenseErrorLoad, + } = useExpense(expenseToEdit); + console.log(data) + const [ExpenseType, setExpenseType] = useState(); + const dispatch = useDispatch(); + const { + ExpenseTypes, + loading: ExpenseLoading, + error: ExpenseError, + } = useExpenseType(); + const schema = ExpenseSchema(ExpenseTypes); + const { + register, + handleSubmit, + watch, + setValue, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: defaultExpense, + }); + + const selectedproject = watch("projectId"); + + const { + projectNames, + loading: projectLoading, + error, + isError: isProjectError, + } = useProjectName(); + + const { + PaymentModes, + loading: PaymentModeLoading, + error: PaymentModeError, + } = usePaymentMode(); + const { + ExpenseStatus, + loading: StatusLoadding, + error: stausError, + } = useExpenseStatus(); + const { + data: employees, + isLoading: EmpLoading, + isError: isEmployeeError, + } = useEmployeesNameByProject(selectedproject); + const files = watch("billAttachments"); + const onFileChange = async (e) => { + const newFiles = Array.from(e.target.files); + if (newFiles.length === 0) return; + + const existingFiles = watch("billAttachments") || []; + + const parsedFiles = await Promise.all( + newFiles.map(async (file) => { + const base64Data = await toBase64(file); + return { + fileName: file.name, + base64Data, + contentType: file.type, + fileSize: file.size, + description: "", + isActive: true, + }; + }) + ); + + const combinedFiles = [ + ...existingFiles, + ...parsedFiles.filter( + (newFile) => + !existingFiles.some( + (f) => + f.fileName === newFile.fileName && f.fileSize === newFile.fileSize + ) + ), + ]; + + setValue("billAttachments", combinedFiles, { + shouldDirty: true, + shouldValidate: true, + }); + }; + + const toBase64 = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result.split(",")[1]); + reader.onerror = (error) => reject(error); + }); + const removeFile = (index) => { + if (expenseToEdit) { + const newFiles = files.map((file, i) => { + if (file.documentId !== index) return file; + return { + ...file, + isActive: false, + }; + }); + setValue("billAttachments", newFiles, { shouldValidate: true }); + } else { + const newFiles = files.filter((_, i) => i !== index); + setValue("billAttachments", newFiles, { shouldValidate: true }); + } + }; + + useEffect(() => { + if (expenseToEdit && data ) { + + reset({ + projectId: data.project.id || "", + expensesTypeId: data.expensesType.id || "", + paymentModeId: data.paymentMode.id || "", + paidById: data.paidBy.id || "", + transactionDate: data.transactionDate?.slice(0, 10) || "", + transactionId: data.transactionId || "", + description: data.description || "", + location: data.location || "", + supplerName: data.supplerName || "", + amount: data.amount || "", + noOfPersons: data.noOfPersons || "", + billAttachments: data.documents + ? data.documents.map((doc) => ({ + fileName: doc.fileName, + base64Data: null, + contentType: doc.contentType, + documentId: doc.documentId, + fileSize: 0, + description: "", + preSignedUrl: doc.preSignedUrl, + isActive: doc.isActive ?? true, + })) + : [], + }); + } + }, [data, reset, employees]); + const { mutate: ExpenseUpdate, isPending } = useUpdateExpense(() => + handleClose() + ); + const { mutate: CreateExpense, isPending: createPending } = useCreateExpnse( + () => { + handleClose(); + } + ); + const onSubmit = (fromdata) => { + 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 }); + } else { + CreateExpense(payload); + } + }; + const ExpenseTypeId = watch("expensesTypeId"); + + useEffect(() => { + setExpenseType(ExpenseTypes?.find((type) => type.id === ExpenseTypeId)); + }, [ExpenseTypeId]); + + const handleClose = () => { + reset(); + closeModal(); + }; + if (StatusLoadding || projectLoading || ExpenseLoading || isLoading) + return ; + + + return ( +
+
+ {expenseToEdit ? "Update Expense " : "Create New Expense"} +
+
+
+
+ + + {errors.projectId && ( + {errors.projectId.message} + )} +
+ +
+ + + {errors.expensesTypeId && ( + + {errors.expensesTypeId.message} + + )} +
+
+ +
+
+ + + {errors.paymentModeId && ( + + {errors.paymentModeId.message} + + )} +
+ +
+ + + {errors.paidById && ( + {errors.paidById.message} + )} +
+
+ +
+
+ + + + {errors.transactionDate && ( + + {errors.transactionDate.message} + + )} +
+ +
+ + + {errors.amount && ( + {errors.amount.message} + )} +
+
+ +
+
+ + + {errors.supplerName && ( + + {errors.supplerName.message} + + )} +
+ +
+ + + {errors.location && ( + {errors.location.message} + )} +
+
+
+
+ + + {errors.transactionId && ( + + {errors.transactionId.message} + + )} +
+ + {ExpenseType?.noOfPersonsRequired && ( +
+ + + {errors.noOfPersons && ( + + {errors.noOfPersons.message} + + )} +
+ )} +
+ +
+
+ + + {errors.description && ( + + {errors.description.message} + + )} +
+
+ +
+
+ + +
document.getElementById("billAttachments").click()} + > + + + Click to select or click here to browse + + (PDF, JPG, PNG, max 5MB) + + { + onFileChange(e); + e.target.value = ""; + }} + /> +
+ {errors.billAttachments && ( + + {errors.billAttachments.message} + + )} + {files.length > 0 && ( +
+ {files + .filter((file) => { + if (expenseToEdit) { + return file.isActive; + } + return true; + }) + .map((file, idx) => ( + +
+ + {file.fileName} + + + {file.fileSize ? formatFileSize(file.fileSize) : ""} + +
+ { + e.preventDefault(); + removeFile(expenseToEdit ? file.documentId : idx); + }} + > +
+ ))} +
+ )} + + {Array.isArray(errors.billAttachments) && + errors.billAttachments.map((fileError, index) => ( +
+ { + (fileError?.fileSize?.message || + fileError?.contentType?.message || + fileError?.base64Data?.message, + fileError?.documentId?.message) + } +
+ ))} +
+
+ +
+ {" "} + + +
+
+
+ ); +}; + +export default ManageExpense; diff --git a/src/components/Expenses/PreviewDocument.jsx b/src/components/Expenses/PreviewDocument.jsx new file mode 100644 index 00000000..9c0f5c49 --- /dev/null +++ b/src/components/Expenses/PreviewDocument.jsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +const PreviewDocument = ({ imageUrl }) => { + const [loading, setLoading] = useState(true); + + return ( +
+ {loading && ( +
+ Loading... +
+ )} + Full View setLoading(false)} + /> +
+ ); +}; + +export default PreviewDocument; diff --git a/src/components/Expenses/ViewExpense.jsx b/src/components/Expenses/ViewExpense.jsx new file mode 100644 index 00000000..8d516b6c --- /dev/null +++ b/src/components/Expenses/ViewExpense.jsx @@ -0,0 +1,555 @@ +import React, { useState, useMemo } from "react"; +import { + useActionOnExpense, + useExpense, + useHasAnyPermission, +} from "../../hooks/useExpense"; +import { formatUTCToLocalTime } from "../../utils/dateUtils"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +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 { + EXPENSE_REJECTEDBY, + 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"; +import ExpenseStatusLogs from "./ExpenseStatusLogs"; + +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: defaultActionValues, + }); + + const userPermissions = useSelector( + (state) => state?.globalVariables?.loginUser?.featurePermissions || [] + ); + const CurrentUser = useSelector( + (state) => state?.globalVariables?.loginUser?.employeeInfo + ); + + const nextStatusWithPermission = useMemo(() => { + if (!Array.isArray(data?.nextStatus)) return []; + + return data.nextStatus.filter((status) => { + const permissionIds = Array.isArray(status?.permissionIds) + ? status.permissionIds + : []; + + if (permissionIds.length === 0) return true; + if (permissionIds.includes(PROCESS_EXPENSE)) { + setIsPaymentProcess(true); + } + return permissionIds.some((id) => userPermissions.includes(id)); + }); + }, [data, userPermissions]); + + const IsRejectedExpense = useMemo(() => { + return EXPENSE_REJECTEDBY.includes(data?.status?.id); + }, [data]); + + const isCreatedBy = useMemo(() => { + return data?.createdBy.id === CurrentUser?.id; + }, [data, CurrentUser]); + + 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, + comment: formData.comment, + }; + MakeAction(Payload); + }; + + if (isLoading) return ; + if (isError) return ; + const handleImageLoad = (id) => { + setImageLoaded((prev) => ({ ...prev, [id]: true })); + }; + + return ( +
+
+
+
Expense Details
+
+
+
+ {/* */} +
{data?.description}
+
+ {/* Row 1 */} +
+
+ +
+ {formatUTCToLocalTime(data?.transactionDate)} +
+
+
+
+
+ +
{data?.expensesType?.name}
+
+
+ + {/* Row 2 */} +
+
+ +
{data?.supplerName}
+
+
+
+
+ +
₹ {data.amount}
+
+
+ + {/* Row 3 */} +
+
+ +
{data?.paymentMode?.name}
+
+
+
+
+ +
+ {data?.paidBy?.firstName} {data?.paidBy?.lastName} +
+
+
+ + {/* Row 4 */} +
+
+ + + {data?.status?.name} + +
+
+
+
+ +
{data.preApproved ? "Yes" : "No"}
+
+
+ +
+
+ +
{data?.project?.name}
+
+
+
+
+ +
+ {formatUTCToLocalTime(data?.createdAt, true)} +
+
+
+ + {/* Row 6 */} + {data.createdBy && ( +
+
+ +
+ + + {`${data.createdBy?.firstName ?? ""} ${ + data.createdBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+
+
+ )} + + {/* {data.reviewedBy && ( +
+
+ +
+ + + {`${data.reviewedBy?.firstName ?? ""} ${ + data.reviewedBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+
+
+ )} + + {data.approvedBy && ( +
+
+ +
+ + + {`${data.approvedBy?.firstName ?? ""} ${ + data.approvedBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+
+
+ )} + {data.processedBy && ( +
+
+ +
+ + + {`${data.processedBy?.firstName ?? ""} ${ + data.processedBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+
+
+ )} */} +
+ + + {/*
+ +
{data?.description}
+
*/} +
+ + + {data?.documents?.map((doc) => { + const getIconByType = (type) => { + if (!type) return "bx bx-file"; + + if (type.includes("pdf")) return "bxs-file-pdf"; + if (type.includes("word")) return "bxs-file-doc"; + if (type.includes("excel") || type.includes("spreadsheet")) + return "bxs-file-xls"; + if (type.includes("image")) return "bxs-file-image"; + if (type.includes("zip") || type.includes("rar")) + return "bxs-file-archive"; + + return "bx bx-file"; + }; + + const isImage = doc.contentType?.includes("image"); + + return ( +
+
{ + if (isImage) { + setDocumentView({ + IsOpen: true, + Image: doc.preSignedUrl, + }); + } + }} + > + +
+ +
+ {doc.fileName} +
+ +
+
+
+ ); + })} +
+ + + {data.expensesReimburse && ( +
+
+ + {data.expensesReimburse.reimburseTransactionId || "N/A"} +
+
+ + {formatUTCToLocalTime(data.expensesReimburse.reimburseDate)} +
+ + {data.expensesReimburse && ( + <> +
+ + + + {`${data?.expensesReimburse?.reimburseBy?.firstName} ${data?.expensesReimburse?.reimburseBy?.lastName}`.trim()} + +
+ + )} +
+ )} +
+ + {Array.isArray(data?.nextStatus) && data.nextStatus.length > 0 && ( + <> + {IsPaymentProcess && nextStatusWithPermission?.length > 0 && ( +
+
+ + + {errors.reimburseTransactionId && ( + + {errors.reimburseTransactionId.message} + + )} +
+
+ + + {errors.reimburseDate && ( + + {errors.reimburseDate.message} + + )} +
+
+ + +
+
+ )} +
+ {((nextStatusWithPermission.length > 0 && !IsRejectedExpense) || + (IsRejectedExpense && isCreatedBy)) && ( + <> + +