diff --git a/index.html b/index.html index 5bc1c55b..121acff3 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,8 @@ + + diff --git a/public/assets/css/hover-utility.css b/public/assets/css/hover-utility.css new file mode 100644 index 00000000..ef080092 --- /dev/null +++ b/public/assets/css/hover-utility.css @@ -0,0 +1,86 @@ +/* Hover background color */ +.hover-bg-light:hover { + background-color: #f8f9fa !important; +} + +.hover-bg-primary:hover { + background-color: var(--bs-primary) !important; + color: #fff !important; +} + +.hover-bg-danger:hover { + background-color: #dc3545 !important; + color: #fff !important; +} + +.hover-bg-success:hover { + background-color: var(--bg-success) !important; + color: #fff !important; +} + +.hover-bg-warning:hover { + background-color: #ffc107 !important; + color: #212529 !important; +} + +/* Hover text color */ +.hover-text-primary:hover { + color: #0d6efd !important; +} + +.hover-text-danger:hover { + color: #dc3545 !important; +} + +.hover-text-success:hover { + color: #198754 !important; +} + +.hover-text-muted:hover { + color: #6c757d !important; +} + +/* Hover shadow */ +.hover-shadow-sm:hover { + box-shadow: 0 .125rem .25rem rgba(0, 0, 0, 0.075); +} + +.hover-shadow:hover { + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, 0.15); +} + +.hover-shadow-lg:hover { + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175); +} + +/* Hover scale */ +.hover-scale:hover { + transform: scale(1.05); +} + +.hover-scale-sm:hover { + transform: scale(1.03); +} + +.hover-scale-lg:hover { + transform: scale(1.1); +} + +/* Add smooth transition to hover effects */ +.hover-transition { + transition: all 0.2s ease-in-out; +} + +/* Hover border color */ +.hover-border-primary:hover { + border-color: #0d6efd !important; +} + +.hover-border-danger:hover { + border-color: #dc3545 !important; +} + +.thick-divider { + height: 3px; + font-size: 10px; +} \ No newline at end of file diff --git a/public/assets/css/skeleton.css b/public/assets/css/skeleton.css new file mode 100644 index 00000000..2ee909d2 --- /dev/null +++ b/public/assets/css/skeleton.css @@ -0,0 +1,32 @@ +/* skeleton.css */ +.skeleton { + background-color: #e2e8f0; /* Tailwind's gray-300 */ + border-radius: 0.25rem; /* Tailwind's rounded */ + position: relative; + overflow: hidden; +} + +.skeleton::after { + content: ''; + display: block; + position: absolute; + top: 0; left: -150px; + height: 100%; + width: 150px; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.4), + transparent + ); + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + left: -150px; + } + 100% { + left: 100%; + } +} \ No newline at end of file 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/Context/FabContext.jsx b/src/Context/FabContext.jsx index 7151e6d7..d78d4078 100644 --- a/src/Context/FabContext.jsx +++ b/src/Context/FabContext.jsx @@ -4,9 +4,30 @@ const FabContext = createContext(); export const FabProvider = ({ children }) => { const [actions, setActions] = useState([]); + const [showTrigger, setShowTrigger] = useState(true); + const [isOffcanvasOpen, setIsOffcanvasOpen] = useState(false); + const [offcanvas, setOffcanvas] = useState({ + isOpen: false, + title: "", + content: null, + }); + + const openOffcanvas = (title, content) => { + setOffcanvas({ isOpen: true, title, content }); + setTimeout(() => { + const offcanvasElement = document.getElementById("globalOffcanvas"); + if (offcanvasElement) { + const bsOffcanvas = new window.bootstrap.Offcanvas(offcanvasElement); + bsOffcanvas.show(); + } + }, 100); + }; +const setOffcanvasContent = (title, content) => { + setOffcanvas(prev => ({ ...prev, title, content })); +}; return ( - + {children} ); diff --git a/src/components/Expenses/ExpenseFilterPanel.jsx b/src/components/Expenses/ExpenseFilterPanel.jsx new file mode 100644 index 00000000..b83202a4 --- /dev/null +++ b/src/components/Expenses/ExpenseFilterPanel.jsx @@ -0,0 +1,200 @@ +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(() => { + return [ + { id: "transactionDate", name: "Transaction Date" }, + { id: "status", name: "Status" }, + { id: "submittedBy", name: "Submitted By" }, + { id: "project", name: "Project" }, + { id: "paymentMode", name: "Payment Mode" }, + { id: "expensesType", name: "Expense Type" }, + { id: "createdAt", name: "Submitted Date" } + ].sort((a, b) => a.name.localeCompare(b.name)); +}, []); + + + 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..2c494c09 --- /dev/null +++ b/src/components/Expenses/ExpenseList.jsx @@ -0,0 +1,325 @@ +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 "submittedBy": + key = `${item.createdBy?.firstName ?? ""} ${ + item.createdBy?.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: "Submitted By", + label: "Submitted By", + align: "text-start", + getValue: (e) => + `${e.createdBy?.firstName ?? ""} ${e.createdBy?.lastName ?? ""}`.trim() || + "N/A", + customRender: (e) => ( +
+ + + {`${e.createdBy?.firstName ?? ""} ${ + e.createdBy?.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.message}
; + + 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..b1339228 --- /dev/null +++ b/src/components/Expenses/ExpenseSchema.js @@ -0,0 +1,166 @@ +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" }), + gstNumber :z.string().optional(), + 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: "", + gstNumber:"", + 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..dbe1a5d7 --- /dev/null +++ b/src/components/Expenses/ExpenseSkeleton.jsx @@ -0,0 +1,306 @@ +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) => ( +
+ {/* Icon placeholder */} +
+ {/* Filename placeholder */} +
+
+ ))} +
+
+ +
+ +
+ + +
+ {[...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 */} + + + {/* Submitted By (Avatar + name) */} + + {/* Submitted */} + + + {/* Amount */} + + + {/* Status */} + + + {/* Action */} + + + ))} + + ))} + +
+
Expense Type
+
+
Payment Mode
+
Submitted BySubmittedAmountStatusAction
+ +
+ + + + +
+ + +
+
+ + + + + + +
+ {[...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..575508db --- /dev/null +++ b/src/components/Expenses/ExpenseStatusLogs.jsx @@ -0,0 +1,68 @@ +import { useState,useMemo } from "react"; +import Avatar from "../common/Avatar"; +import { formatUTCToLocalTime } from "../../utils/dateUtils"; + + +const ExpenseStatusLogs = ({ data }) => { + const [visibleCount, setVisibleCount] = useState(4); + + const sortedLogs = useMemo(() => { + if (!data?.expenseLogs) return []; + return [...data.expenseLogs].sort( + (a, b) => new Date(b.updateAt) - new Date(a.updateAt) + ); + }, [data?.expenseLogs]); + + const logsToShow = sortedLogs.slice(0, visibleCount); + + const handleShowMore = () => { + setVisibleCount((prev) => prev + 4); + }; + + return ( + <> +
+ {logsToShow.map((log) => ( +
+ + +
+
+
+ {`${log.updatedBy.firstName} ${log.updatedBy.lastName}`} + + {log.action} + + + {formatUTCToLocalTime(log.updateAt,true)} + +
+
+ {log.comment} +
+
+
+
+ ))} +
+ + {sortedLogs.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..495ebdd9 --- /dev/null +++ b/src/components/Expenses/ManageExpense.jsx @@ -0,0 +1,578 @@ +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 || "", + gstNumber:data.gstNumber || "", + 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} + + )} +
+
+ + + {errors.gstNumber && ( + + {errors.gstNumber.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..568b695d --- /dev/null +++ b/src/components/Expenses/ViewExpense.jsx @@ -0,0 +1,466 @@ +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, getIconByFileType } 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, isFetching } = 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?.gstNumber && ( +
+
+ +
{data?.gstNumber}
+
+
+ )} + + {/* 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.paidBy?.firstName ?? ""} ${ + data.paidBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+
+
+
+ +
+ + +
+ {data?.documents?.map((doc) => { + 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)) && ( + <> + + + + {errors.description && ( +

{errors.description.message}

+ )} +
+
+ {" "} + +
+
+ + +
+
+ ); +}; + +export default ManageExpenseType; diff --git a/src/components/master/ManagePaymentMode.jsx b/src/components/master/ManagePaymentMode.jsx new file mode 100644 index 00000000..b05856db --- /dev/null +++ b/src/components/master/ManagePaymentMode.jsx @@ -0,0 +1,95 @@ +import React, { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCreatePaymentMode, useUpdatePaymentMode } from "../../hooks/masterHook/useMaster"; + +const ExpnseSchema = z.object({ + name: z.string().min(1, { message: "Name is required" }), + description: z.string().min(1, { message: "Description is required" }), +}); + +const ManagePaymentMode = ({ data = null, onClose }) => { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(ExpnseSchema), + defaultValues: { name: "", description: "" }, + }); + + const { mutate: CreatePaymentMode, isPending } = useCreatePaymentMode(() => + onClose?.() + ); + const {mutate:UpdatePaymentMode,isPending:Updating} = useUpdatePaymentMode(()=>onClose?.()) + + const onSubmit = (payload) => { + if(data){ + UpdatePaymentMode({id:data.id,payload:{...payload,id:data.id}}) + }else( + CreatePaymentMode(payload) + ) + + }; + + useEffect(()=>{ + if(data){ + reset({ + name:data.name ?? "", + description:data.description ?? "" + }) + } + },[data]) + + + return ( +
+
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + + + {errors.description && ( +

{errors.description.message}

+ )} +
+ +
+ + +
+
+ ); +}; + +export default ManagePaymentMode; diff --git a/src/components/master/MasterModal.jsx b/src/components/master/MasterModal.jsx index 52e22114..0a472727 100644 --- a/src/components/master/MasterModal.jsx +++ b/src/components/master/MasterModal.jsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from "react"; - import CreateRole from "./CreateRole"; import DeleteMaster from "./DeleteMaster"; import EditRole from "./EditRole"; @@ -18,67 +17,45 @@ import CreateContactTag from "./CreateContactTag"; import EditContactCategory from "./EditContactCategory"; import EditContactTag from "./EditContactTag"; import { useDeleteMasterItem } from "../../hooks/masterHook/useMaster"; +import ManageExpenseType from "./ManageExpenseType"; +import ManagePaymentMode from "./ManagePaymentMode"; +import ManageExpenseStatus from "./ManageExpenseStatus"; + const MasterModal = ({ modaldata, closeModal }) => { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { mutate: deleteMasterItem, isPending } = useDeleteMasterItem(); - // const handleSelectedMasterDeleted = async () => - // { - // debugger - // const deleteFn = MasterRespository[modaldata.masterType]; - // if (!deleteFn) { - // showToast(`No delete strategy defined for master type`,"error"); - // return false; - // } - // try - // { - // const response = await deleteFn( modaldata?.item?.id ); - // const selected_cachedData = getCachedData( modaldata?.masterType ); - // const updated_master = selected_cachedData?.filter(item => item.id !== modaldata?.item.id); - // cacheData( modaldata?.masterType, updated_master ) - - // showToast(`${modaldata?.masterType} is deleted successfully`, "success"); - // handleCloseDeleteModal() - - // } catch ( error ) - // { - // const message = error.response.data.message || error.message || "Error occured api during call" - // showToast(message, "success"); - // } - // } - const handleSelectedMasterDeleted = () => { - if (!modaldata?.masterType || !modaldata?.item?.id) { + const { masterType, item, validateFn } = modaldata || {}; + if (!masterType || !item?.id) { showToast("Missing master type or item", "error"); return; } + deleteMasterItem( - { - masterType: modaldata.masterType, - item: modaldata.item, - validateFn: modaldata.validateFn, // optional - }, - { - onSuccess: () => { - handleCloseDeleteModal(); - }, - } + { masterType, item, validateFn }, + { onSuccess: handleCloseDeleteModal } ); }; + const handleCloseDeleteModal = () => { + setIsDeleteModalOpen(false); + closeModal(); + }; + useEffect(() => { if (modaldata?.modalType === "delete") { setIsDeleteModalOpen(true); } }, [modaldata]); - const handleCloseDeleteModal = () => { - setIsDeleteModalOpen(false); + if (!modaldata?.modalType) { closeModal(); - }; + return null; + } - if (modaldata?.modalType === "delete" && isDeleteModalOpen) { + if (modaldata.modalType === "delete" && isDeleteModalOpen) { return (
{
); } + + const renderModalContent = () => { + const { modalType, item, masterType } = modaldata; + + const modalComponents = { + "Application Role": , + "Edit-Application Role": , + "Job Role": , + "Edit-Job Role": , + "Activity": , + "Edit-Activity": , + "Work Category": , + "Edit-Work Category": , + "Contact Category": , + "Edit-Contact Category": , + "Contact Tag": , + "Edit-Contact Tag": , + "Expense Type":, + "Edit-Expense Type":, + "Payment Mode":, + "Edit-Payment Mode":, + "Expense Status":, + "Edit-Expense Status": + }; + + return modalComponents[modalType] || null; + }; + + const isLargeModal = ["Application Role", "Edit-Application Role"].includes( + modaldata.modalType + ); + return ( +
+
); diff --git a/src/pages/Activities/AttendancePage.jsx b/src/pages/Activities/AttendancePage.jsx index 65917c3f..d6a3463b 100644 --- a/src/pages/Activities/AttendancePage.jsx +++ b/src/pages/Activities/AttendancePage.jsx @@ -27,7 +27,7 @@ const AttendancePage = () => { const [ShowPending, setShowPending] = useState(false); const queryClient = useQueryClient(); const loginUser = getCachedProfileData(); - var selectedProject = useSelector((store) => store.localVariables.projectId); + const selectedProject = useSelector((store) => store.localVariables.projectId); const dispatch = useDispatch(); const [attendances, setAttendances] = useState(); @@ -155,28 +155,32 @@ const AttendancePage = () => { -
- {activeTab === "all" && ( -
- -
- )} - {activeTab === "logs" && ( -
- -
- )} - {activeTab === "regularization" && DoRegularized && ( -
- -
- )} -
+
+ {selectedProject ? ( + <> + {activeTab === "all" && ( +
+ +
+ )} + {activeTab === "logs" && ( +
+ +
+ )} + {activeTab === "regularization" && DoRegularized && ( +
+ +
+ )} + + ) : ( +
+ Please Select Project! +
+ )} +
+
diff --git a/src/pages/Expense/ExpensePage.jsx b/src/pages/Expense/ExpensePage.jsx new file mode 100644 index 00000000..a8be4e72 --- /dev/null +++ b/src/pages/Expense/ExpensePage.jsx @@ -0,0 +1,195 @@ +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 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"; + +// Schema & Defaults +import { + defaultFilter, + SearchSchema, +} from "../../components/Expenses/ExpenseSchema"; + +// Context +export const ExpenseContext = createContext(); +export const useExpenseContext = () => { + const context = useContext(ExpenseContext); + if (!context) { + throw new Error("useExpenseContext must be used within an ExpenseProvider"); + } + return context; +}; + +const ExpensePage = () => { + 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 { setOffcanvasContent, setShowTrigger } = useFab(); + + const methods = useForm({ + resolver: zodResolver(SearchSchema), + defaultValues: defaultFilter, + }); + + const { reset } = methods; + + const clearFilter = () => { + setFilter(defaultFilter); + reset(); + }; + + useEffect(() => { + setShowTrigger(true); + setOffcanvasContent( + "Expense Filters", + + ); + + return () => { + setShowTrigger(false); + setOffcanvasContent("", null); + }; + }, []); + + const contextValue = { + setViewExpense, + setManageExpenseModal, + setDocumentView, + }; + + return ( + +
+ + + {(IsViewAll || IsViewSelf || IsCreatedAble) ? ( + <> +
+
+
+
+
+ + setSearchText(e.target.value)} + /> +
+
+ +
+ {IsCreatedAble && ( + + )} +
+
+
+
+ + + + ) : ( +
+ +

Access Denied: You don't have permission to perform this action!

+
+ )} + + {/* Modals */} + {ManageExpenseModal.IsOpen && ( + setManageExpenseModal({ IsOpen: null, expenseId: null })} + > + setManageExpenseModal({ IsOpen: null, expenseId: null })} + /> + + )} + + {viewExpense.view && ( + setViewExpense({ expenseId: null, view: false })} + > + + + )} + + {ViewDocument.IsOpen && ( + setDocumentView({ IsOpen: false, Image: null })} + > + + + )} +
+
+ ); +}; + +export default ExpensePage; diff --git a/src/pages/employee/EmployeeList.jsx b/src/pages/employee/EmployeeList.jsx index 73ef5901..a7695c4d 100644 --- a/src/pages/employee/EmployeeList.jsx +++ b/src/pages/employee/EmployeeList.jsx @@ -295,12 +295,12 @@ const EmployeeList = () => { {ViewTeamMember ? ( //
-
+
{/* Switches: All Employees + Inactive */} @@ -581,7 +581,7 @@ const EmployeeList = () => { {item.email ? ( - + {item.email} ) : ( diff --git a/src/pages/master/MasterPage.jsx b/src/pages/master/MasterPage.jsx index c11f5861..7904e6af 100644 --- a/src/pages/master/MasterPage.jsx +++ b/src/pages/master/MasterPage.jsx @@ -1,94 +1,94 @@ import React, { useState, useEffect, useMemo } from "react"; import Breadcrumb from "../../components/common/Breadcrumb"; import MasterModal from "../../components/master/MasterModal"; -import { mastersList} from "../../data/masters"; +import { mastersList } from "../../data/masters"; import { useDispatch, useSelector } from "react-redux"; import { changeMaster } from "../../slices/localVariablesSlice"; import useMaster from "../../hooks/masterHook/useMaster" import MasterTable from "./MasterTable"; import { getCachedData } from "../../slices/apiDataManager"; -import {useHasUserPermission} from "../../hooks/useHasUserPermission"; +import { useHasUserPermission } from "../../hooks/useHasUserPermission"; import { MANAGE_MASTER } from "../../utils/constants"; -import {useQueryClient} from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; const MasterPage = () => { -const [modalConfig, setModalConfig] = useState({ modalType: "", item: null, masterType: null }); -const [searchTerm, setSearchTerm] = useState(''); -const [filteredResults, setFilteredResults] = useState([]); -const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [modalConfig, setModalConfig] = useState({ modalType: "", item: null, masterType: null }); + const [searchTerm, setSearchTerm] = useState(''); + const [filteredResults, setFilteredResults] = useState([]); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); -const hasMasterPermission = useHasUserPermission(MANAGE_MASTER); -const dispatch = useDispatch(); -const selectedMaster = useSelector((store) => store.localVariables.selectedMaster); -const queryClient = useQueryClient(); + const hasMasterPermission = useHasUserPermission(MANAGE_MASTER); + const dispatch = useDispatch(); + const selectedMaster = useSelector((store) => store.localVariables.selectedMaster); + const queryClient = useQueryClient(); -const { data: masterData = [], loading, error, RecallApi } = useMaster(); + const { data: masterData = [], loading, error, RecallApi } = useMaster(); -const openModal = () => setIsCreateModalOpen(true); + const openModal = () => setIsCreateModalOpen(true); -const closeModal = () => { - setIsCreateModalOpen(false); - setModalConfig(null); - - // Clean up Bootstrap modal manually - const modalEl = document.getElementById('master-modal'); - modalEl?.classList.remove('show'); - if (modalEl) modalEl.style.display = 'none'; - - document.body.classList.remove('modal-open'); - document.body.style.overflow = 'auto'; - - document.querySelectorAll('.modal-backdrop').forEach((el) => el.remove()); -}; - -const handleModalData = (modalType, item, masterType = selectedMaster) => { - setModalConfig({ modalType, item, masterType }); -}; - -const handleSearch = (e) => { - const value = e.target.value.toLowerCase(); - setSearchTerm(value); - - if (!masterData?.length) return; - - const results = masterData.filter((item) => - Object.values(item).some( - (field) => field?.toString().toLowerCase().includes(value) - ) - ); - setFilteredResults(results); -}; -const displayData = useMemo(() => { - if (searchTerm) return filteredResults; - return queryClient.getQueryData(["masterData", selectedMaster]) || masterData; -}, [searchTerm, filteredResults, selectedMaster, masterData]); - -const columns = useMemo(() => { - if (!displayData?.length) return []; - return Object.keys(displayData[0]).map((key) => ({ - key, - label: key.toUpperCase(), - })); -}, [displayData]); - -useEffect(() => { - if (modalConfig) openModal(); -}, [modalConfig]); - -useEffect(() => { - return () => { + const closeModal = () => { setIsCreateModalOpen(false); - closeModal(); + setModalConfig(null); + + // Clean up Bootstrap modal manually + const modalEl = document.getElementById('master-modal'); + modalEl?.classList.remove('show'); + if (modalEl) modalEl.style.display = 'none'; + + document.body.classList.remove('modal-open'); + document.body.style.overflow = 'auto'; + + document.querySelectorAll('.modal-backdrop').forEach((el) => el.remove()); }; -}, []); + + const handleModalData = (modalType, item, masterType = selectedMaster) => { + setModalConfig({ modalType, item, masterType }); + }; + + const handleSearch = (e) => { + const value = e.target.value.toLowerCase(); + setSearchTerm(value); + + if (!masterData?.length) return; + + const results = masterData.filter((item) => + Object.values(item).some( + (field) => field?.toString().toLowerCase().includes(value) + ) + ); + setFilteredResults(results); + }; + const displayData = useMemo(() => { + if (searchTerm) return filteredResults; + return queryClient.getQueryData(["masterData", selectedMaster]) || masterData; + }, [searchTerm, filteredResults, selectedMaster, masterData]); + + const columns = useMemo(() => { + if (!displayData?.length) return []; + return Object.keys(displayData[0]).map((key) => ({ + key, + label: key.toUpperCase(), + })); + }, [displayData]); + + useEffect(() => { + if (modalConfig) openModal(); + }, [modalConfig]); + + useEffect(() => { + return () => { + setIsCreateModalOpen(false); + closeModal(); + }; + }, []); return ( <> {isCreateModalOpen && ( - - + + )}
@@ -100,7 +100,7 @@ useEffect(() => { >
-
+
{ >
- +
diff --git a/src/pages/master/MasterTable.jsx b/src/pages/master/MasterTable.jsx index 0126b339..bc7c10c0 100644 --- a/src/pages/master/MasterTable.jsx +++ b/src/pages/master/MasterTable.jsx @@ -9,13 +9,18 @@ const MasterTable = ({ data, columns, loading, handleModalData }) => { const selectedMaster = useSelector( (store) => store.localVariables.selectedMaster ); - const hiddenColumns = [ + const hiddenColumns = [ "id", "featurePermission", "tenant", "tenantId", "checkLists", "isSystem", + "isActive", + "noOfPersonsRequired", + "color", + "displayName", + "permissionIds" ]; const safeData = Array.isArray(data) ? data : []; @@ -179,7 +184,7 @@ const MasterTable = ({ data, columns, loading, handleModalData }) => { {/* Pagination */} {!loading && safeData.length > 20 && (