diff --git a/index.html b/index.html index 748027e9..a1531220 100644 --- a/index.html +++ b/index.html @@ -27,6 +27,7 @@ + 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/src/components/Expenses/ExpenseList.jsx b/src/components/Expenses/ExpenseList.jsx index bb4d523a..0ef5d418 100644 --- a/src/components/Expenses/ExpenseList.jsx +++ b/src/components/Expenses/ExpenseList.jsx @@ -5,9 +5,11 @@ import { useExpenseContext } from "../../pages/Expense/ExpensePage"; import { formatDate, formatUTCToLocalTime } from "../../utils/dateUtils"; import Pagination from "../common/Pagination"; import { ITEMS_PER_PAGE } from "../../utils/constants"; +import { AppColorconfig, getColorNameFromHex } from "../../utils/appUtils"; +import { ExpenseTableSkeleton } from "./ExpenseSkeleton"; const ExpenseList = () => { - const { setViewExpense } = useExpenseContext(); + const { setViewExpense,setManageExpenseModal } = useExpenseContext(); const [currentPage, setCurrentPage] = useState(1); const pageSize = 10; @@ -21,9 +23,10 @@ const ExpenseList = () => { }; - const { data, isLoading, isError,isInitialLoading } = useExpenseList(2, currentPage, filter); - if (isInitialLoading) return
Loading...
; - const items = data.data ?? []; + const { data, isLoading, isError,isInitialLoading,error,isFetching } = useExpenseList(10, currentPage, filter); + if (isInitialLoading ) return ; + if (isError) return
{error}
; + const items = data?.data ?? []; const totalPages = data?.totalPages ?? 1; const hasMore = currentPage < totalPages; @@ -125,7 +128,7 @@ const ExpenseList = () => { - {isLoading && ( + {/* {isLoading && ( Loading... @@ -139,7 +142,7 @@ const ExpenseList = () => { No expenses found. - )} + )} */} {!isInitialLoading && items.map((expense) => ( @@ -170,19 +173,13 @@ const ExpenseList = () => { - {expense.amount} + {expense?.amount} - {expense.status?.name || "Unknown"} + + {expense.status?.displayName || "Unknown"} @@ -197,14 +194,12 @@ const ExpenseList = () => { setManageExpenseModal({IsOpen:true,expenseId:expense.id})} > - diff --git a/src/components/Expenses/ExpenseSchema.js b/src/components/Expenses/ExpenseSchema.js index 99b75633..705efe6b 100644 --- a/src/components/Expenses/ExpenseSchema.js +++ b/src/components/Expenses/ExpenseSchema.js @@ -35,14 +35,16 @@ export const ExpenseSchema = (expenseTypes) => { .array( z.object({ fileName: z.string().min(1, { message: "Filename is required" }), - base64Data: z.string().min(1, { message: "File data 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" }), @@ -68,6 +70,21 @@ export const ExpenseSchema = (expenseTypes) => { }); }; +export const defaultExpense = { + projectId: "", + expensesTypeId: "", + paymentModeId: "", + paidById: "", + transactionDate: "", + transactionId: "", + description: "", + location: "", + supplerName: "", + amount: "", + noOfPersons: "", + billAttachments: [], + } + export const ActionSchema = z.object({ comment : z.string().min(1,{message:"Please leave comment"}), selectedStatus: z.string().min(1, { message: "Please select a status" }), diff --git a/src/components/Expenses/ExpenseSkeleton.jsx b/src/components/Expenses/ExpenseSkeleton.jsx new file mode 100644 index 00000000..904c2e9d --- /dev/null +++ b/src/components/Expenses/ExpenseSkeleton.jsx @@ -0,0 +1,218 @@ +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 = "" }) => ( +
+); +export const ExpenseTableSkeleton = ({ rows = 5 }) => { + return ( + + + + + + + + + + + + + + + {[...Array(rows)].map((_, idx) => ( + + {/* Date Time colSpan=2 */} + + + {/* Expense Type */} + + + {/* Payment Mode */} + + + {/* Paid By (Avatar + name) */} + + + {/* Amount */} + + + {/* Status */} + + + {/* Action (icons) */} + + + ))} + +
+
Date Time
+
+
Expense Type
+
+
Payment Mode
+
Paid ByAmountStatusAction
+
+ +
+
+ + + + +
+ + +
+
+ + + + +
+ {[...Array(3)].map((__, i) => ( + + ))} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/Expenses/CreateExpense.jsx b/src/components/Expenses/ManageExpense.jsx similarity index 72% rename from src/components/Expenses/CreateExpense.jsx rename to src/components/Expenses/ManageExpense.jsx index cddc79dd..23c13250 100644 --- a/src/components/Expenses/CreateExpense.jsx +++ b/src/components/Expenses/ManageExpense.jsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { ExpenseSchema } from "./ExpenseSchema"; +import { defaultExpense, ExpenseSchema } from "./ExpenseSchema"; import { formatFileSize } from "../../utils/appUtils"; import { useProjectName } from "../../hooks/useProjects"; import { useDispatch, useSelector } from "react-redux"; @@ -16,9 +16,19 @@ import { useEmployeesByProject, } from "../../hooks/useEmployees"; import Avatar from "../common/Avatar"; -import { useCreateExpnse } from "../../hooks/useExpense"; +import { + useCreateExpnse, + useExpense, + useUpdateExpense, +} from "../../hooks/useExpense"; +import ExpenseSkeleton from "./ExpenseSkeleton"; -const CreateExpense = ({closeModal}) => { +const ManageExpense = ({ closeModal, expenseToEdit = null }) => { + const { + data, + isLoading, + error: ExpenseErrorLoad, + } = useExpense(expenseToEdit); const [ExpenseType, setExpenseType] = useState(); const dispatch = useDispatch(); const { @@ -36,20 +46,7 @@ const CreateExpense = ({closeModal}) => { formState: { errors }, } = useForm({ resolver: zodResolver(schema), - defaultValues: { - projectId: "", - expensesTypeId: "", - paymentModeId: "", - paidById: "", - transactionDate: "", - transactionId: "", - description: "", - location: "", - supplerName: "", - amount: "", - noOfPersons: "", - billAttachments: [], - }, + defaultValues: defaultExpense, }); const selectedproject = watch("projectId"); @@ -90,6 +87,7 @@ const CreateExpense = ({closeModal}) => { contentType: file.type, fileSize: file.size, description: "", + isActive:true }; }) ); @@ -115,35 +113,95 @@ const CreateExpense = ({closeModal}) => { new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result.split(",")[1]); // base64 only, no prefix + reader.onload = () => resolve(reader.result.split(",")[1]); reader.onerror = (error) => reject(error); }); const removeFile = (index) => { - const newFiles = files.filter((_, i) => i !== index); - setValue("billAttachments", newFiles, { shouldValidate: true }); + 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 }); + } }; - const {mutate:CreateExpense,isPending} = useCreateExpnse(()=>{ + 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 = (payload) => { - console.log("Form Data:", payload); - - CreateExpense(payload) + 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)); + return () => reset(defaultExpense); }, [ExpenseTypeId]); - const handleClose =()=>{ - reset() - closeModal() - } + const handleClose = () => { + reset(); + closeModal(); + }; + if ( + EmpLoading || + StatusLoadding || + projectLoading || + ExpenseLoading || + isLoading + ) + return ; + return (
-
Create New Expense
+
+ {expenseToEdit ? "Update Expense " : "Create New Expense"} +
@@ -248,16 +306,13 @@ const CreateExpense = ({closeModal}) => { ) : ( employees?.map((emp) => ( )) )} - {errors.paidById && ( - - {errors.paidById.message} - + {errors.paidById && ( + {errors.paidById.message} )}
@@ -392,9 +447,7 @@ const CreateExpense = ({closeModal}) => {
- +
{ {errors.billAttachments.message} )} - {files.length > 0 && ( )} + {Array.isArray(errors.billAttachments) && errors.billAttachments.map((fileError, index) => (
- {fileError?.fileSize?.message || - fileError?.contentType?.message} + { + (fileError?.fileSize?.message || + fileError?.contentType?.message || + fileError?.base64Data?.message, + fileError?.documentId.message) + }
))}
@@ -461,10 +531,19 @@ const CreateExpense = ({closeModal}) => {
{" "} - -
@@ -473,5 +552,4 @@ const CreateExpense = ({closeModal}) => { ); }; -export default CreateExpense; - +export default ManageExpense; diff --git a/src/components/Expenses/ViewExpense.jsx b/src/components/Expenses/ViewExpense.jsx index 989aff98..79088512 100644 --- a/src/components/Expenses/ViewExpense.jsx +++ b/src/components/Expenses/ViewExpense.jsx @@ -5,12 +5,13 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { ActionSchema } from "./ExpenseSchema"; import { useExpenseContext } from "../../pages/Expense/ExpensePage"; +import { getColorNameFromHex } from "../../utils/appUtils"; +import { ExpenseDetailsSkeleton } from "./ExpenseSkeleton"; const ViewExpense = ({ ExpenseId }) => { const { data, isLoading, isError, error } = useExpense(ExpenseId); const [imageLoaded, setImageLoaded] = useState({}); const { setDocumentView } = useExpenseContext(); - const { register, handleSubmit, @@ -36,16 +37,7 @@ const ViewExpense = ({ ExpenseId }) => { MakeAction(Payload); }; - if (isLoading) { - return ( -
-
Loading...
-
- ); - } + if (isLoading) return const handleImageLoad = (id) => { setImageLoaded((prev) => ({ ...prev, [id]: true })); }; @@ -59,7 +51,7 @@ const ViewExpense = ({ ExpenseId }) => {
{/* Expense Info Rows */} -
+
-
+
-
+
-
+
₹ {data.amount}
-
+
-
+
-
-
+
- - {data.status.displayName} + + + {data?.status?.displayName}
-
-
-
+
{data.preApproved ? "Yes" : "No"}
-
-
+
-
{data.project.name}
+
{data?.project?.name}
-
+
- {data.createdBy.firstName} {data.createdBy.lastName} + {data?.createdBy?.firstName} {data?.createdBy?.lastName}
-
+
- {formatUTCToLocalTime(data.createdAt, true)} + {formatUTCToLocalTime(data?.createdAt, true)}
-
+
-
{data.description}
+
{data?.description}
))}
@@ -257,7 +242,7 @@ const ViewExpense = ({ ExpenseId }) => { fontSize: "0.85rem", }} > - {status.displayName || status.name} + {status.displayName || status.name} ))}
diff --git a/src/hooks/useExpense.js b/src/hooks/useExpense.js index 4d5afdf0..abf1dce8 100644 --- a/src/hooks/useExpense.js +++ b/src/hooks/useExpense.js @@ -16,18 +16,16 @@ export const useExpenseList = (pageSize, pageNumber, filter) => { }; export const useExpense = (ExpenseId) => { - - console.log("ExpenseId:", ExpenseId, "Enabled:", ExpenseId !== undefined && ExpenseId !== null); return useQuery({ queryKey: ["Expense", ExpenseId], - queryFn: async () => await ExpenseRepository.GetExpenseDetails(ExpenseId).then( + queryFn: async () => + await ExpenseRepository.GetExpenseDetails(ExpenseId).then( (res) => res.data ), - enabled: !!ExpenseId, + enabled: !!ExpenseId, }); }; - // ---------------------------Mutation--------------------------------------------- export const useCreateExpnse = (onSuccessCallBack) => { @@ -37,8 +35,8 @@ export const useCreateExpnse = (onSuccessCallBack) => { await ExpenseRepository.CreateExpense(payload); }, onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ["Expenses"] }); showToast("Expense Created Successfully", "success"); - queryClient.invalidateQueries({ queryKey: ["expenses"] }); if (onSuccessCallBack) onSuccessCallBack(); }, onError: (error) => { @@ -50,6 +48,68 @@ export const useCreateExpnse = (onSuccessCallBack) => { }); }; +export const useUpdateExpense = (onSuccessCallBack) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, payload }) => { + const response = await ExpenseRepository.UpdateExpense(id, payload); + return response.data; + }, + onSuccess: (updatedExpense, variables) => { + // queryClient.setQueriesData( + // {queryKey:['expenses'],exact:true}, + // (oldData) => { + // if (!oldData || !oldData.data) return oldData; + + // const updatedList = oldData.data.map((expense) => { + // if (expense.id !== variables.id) return expense; + + // return { + // ...expense, + // project: + // expense.project.id !== updatedExpense.project.id + // ? updatedExpense.project + // : expense.project, + // expensesType: + // expense.expensesType.id !== updatedExpense.expensesType.id + // ? updatedExpense.expensesType + // : expense.expensesType, + // paymentMode: + // expense.paymentMode.id !== updatedExpense.paymentMode.id + // ? updatedExpense.paymentMode + // : expense.paymentMode, + // paidBy: + // expense.paidBy.id !== updatedExpense.paidBy.id + // ? updatedExpense.paidBy + // : expense.paidBy, + // createdBy: + // expense.createdBy.id !== updatedExpense.createdBy.id + // ? updatedExpense.createdBy + // : expense.createdBy, + // createdAt: updatedExpense.createdAt, + // status: updatedExpense.status, + // nextStatus: updatedExpense.nextStatus, + // preApproved: updatedExpense.preApproved, + // transactionDate: updatedExpense.transactionDate, + // amount: updatedExpense.amount, + // }; + // }); + + // return { + // ...oldData, + // data: updatedList, + // }; + // } + // ); + queryClient.removeQueries({queryKey:['Expense', variables.id]}); + queryClient.invalidateQueries({queryKey:['Expenses']}) + showToast('Expense updated Successfully', 'success'); + + if (onSuccessCallBack) onSuccessCallBack(); + }, + }); +}; + export const useActionOnExpense = (onSuccessCallBack) => { const queryClient = useQueryClient(); diff --git a/src/pages/Expense/ExpensePage.jsx b/src/pages/Expense/ExpensePage.jsx index 7a98c49e..c4bd8526 100644 --- a/src/pages/Expense/ExpensePage.jsx +++ b/src/pages/Expense/ExpensePage.jsx @@ -1,17 +1,20 @@ import React, { createContext, useContext, useState } from "react"; import ExpenseList from "../../components/Expenses/ExpenseList"; -import CreateExpense from "../../components/Expenses/CreateExpense"; 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"; export const ExpenseContext = createContext(); export const useExpenseContext = () => useContext(ExpenseContext); const ExpensePage = () => { - const [isNewExpense, setNewExpense] = useState(false); + const [ManageExpenseModal, setManageExpenseModal] = useState({ + IsOpen: null, + expenseId: null, + }); const [viewExpense, setViewExpense] = useState({ expenseId: null, view: false, @@ -23,6 +26,7 @@ const ExpensePage = () => { const contextValue = { setViewExpense, + setManageExpenseModal, setDocumentView, }; @@ -58,7 +62,12 @@ const ExpensePage = () => { data-bs-custom-class="tooltip" title="Add New Expense" className={`p-1 me-2 bg-primary rounded-circle `} - onClick={() => setNewExpense(true)} + onClick={() => + setManageExpenseModal({ + IsOpen: true, + expenseId: null, + }) + } > @@ -68,14 +77,24 @@ const ExpensePage = () => {
- - {isNewExpense && ( + {ManageExpenseModal.IsOpen && ( setNewExpense(false)} + closeModal={() => + setManageExpenseModal({ + IsOpen: null, + expenseId: null, + }) + } > - setNewExpense(false)} /> + + setManageExpenseModal({ IsOpen: null, expenseId: null }) + } + /> )} @@ -98,7 +117,7 @@ const ExpensePage = () => { {ViewDocument.IsOpen && ( setDocumentView({ IsOpen: false, Image: null })} > diff --git a/src/repositories/ExpsenseRepository.jsx b/src/repositories/ExpsenseRepository.jsx index 376cb534..eedf5a7d 100644 --- a/src/repositories/ExpsenseRepository.jsx +++ b/src/repositories/ExpsenseRepository.jsx @@ -12,10 +12,11 @@ const ExpenseRepository = { GetExpenseDetails:(id)=>api.get(`/api/Expense/details/${id}`), CreateExpense:(data)=>api.post("/api/Expense/create",data), - UpdateExpense:(id)=>api.put(`/api/Expense/edit/${id}`), + UpdateExpense:(id,data)=>api.put(`/api/Expense/edit/${id}`,data), DeleteExpense:(id)=>api.delete(`/api/Expense/edit/${id}`), - ActionOnExpense:(data)=>api.post('/api/expense/action',data) + ActionOnExpense:(data)=>api.post('/api/expense/action',data), + } diff --git a/src/utils/appUtils.js b/src/utils/appUtils.js index 3f420b31..cd59baa6 100644 --- a/src/utils/appUtils.js +++ b/src/utils/appUtils.js @@ -3,39 +3,34 @@ export const formatFileSize=(bytes)=> { else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"; else return (bytes / (1024 * 1024)).toFixed(2) + " MB"; } -export const getExpenseIcon = (type) => { - switch (type.toLowerCase()) { - case 'vendor/supplier payments': - return 'bx-briefcase'; // Business-related - case 'transport': - return 'bx-car'; // Vehicle or logistics - case 'compliance & safety': - return 'bx-shield-quarter'; // Security/safety - case 'mobilization': - return 'bx-building-house'; // Setup / site infra - case 'procurement': - return 'bx-package'; // Box/package/supplies - case 'maintenance & utilities': - return 'bx-wrench'; // Repair/maintenance - case 'travelling': - return 'bx-plane'; // Personnel delivery - case 'employee welfare': - return 'bx-user-heart'; // Welfare / people - default: - return 'bx-folder'; // Fallback icon +export const AppColorconfig = { + colors: { + primary: '#696cff', + secondary: '#8592a3', + success: '#71dd37', + info: '#03c3ec', + warning: '#ffab00', + danger: '#ff3e1d', + dark: '#233446', + black: '#000', + white: '#fff', + cardColor: '#fff', + bodyBg: '#f5f5f9', + bodyColor: '#697a8d', + headingColor: '#566a7f', + textMuted: '#a1acb8', + borderColor: '#eceef1' } }; -export const getPaymentModeIcon = (mode) => { - switch (mode.toLowerCase()) { - case 'cash': - return 'bx-money'; // Cash/coins - case 'upi': - return 'bx-mobile-alt'; // Mobile payment - case 'cheque': - return 'bx-receipt'; // Paper receipt - case 'netbanking': - return 'bx-globe'; // Online/internet - default: - return 'bx-credit-card'; // Generic fallback +export const getColorNameFromHex = (hex) => { + const normalizedHex = hex?.replace(/'/g, '').toLowerCase(); + const colors = AppColorconfig.colors; + + for (const [name, value] of Object.entries(colors)) { + if (value.toLowerCase() === normalizedHex) { + return name; + } } + + return null; // };