diff --git a/src/components/Expenses/CreateExpense.jsx b/src/components/Expenses/CreateExpense.jsx new file mode 100644 index 00000000..0ed1b119 --- /dev/null +++ b/src/components/Expenses/CreateExpense.jsx @@ -0,0 +1,472 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { 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, +} from "../../hooks/useEmployees"; +import Avatar from "../common/Avatar"; +import { useCreateExpnse } from "../../hooks/useExpense"; + +const CreateExpense = ({closeModal}) => { + const [ExpenseType, setExpenseType] = useState(); + const dispatch = useDispatch(); + const { + ExpenseTypes, + loading: ExpenseLoading, + error: ExpenseError, + } = useExpenseType(); + const schema = ExpenseSchema(ExpenseTypes); + const { + register, + handleSubmit, + watch, + setValue, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + projectId: "", + expensesTypeId: "", + paymentModeId: "", + paidById: "", + transactionDate: "", + transactionId: "", + description: "", + location: "", + supplerName: "", + amount: "", + noOfPersons: "", + billAttachments: [], + }, + }); + + const selectedproject = watch("projectId"); + const selectedProject = useSelector( + (store) => store.localVariables.projectId + ); + const { projectNames, loading: projectLoading, error } = useProjectName(); + + const { + PaymentModes, + loading: PaymentModeLoading, + error: PaymentModeError, + } = usePaymentMode(); + const { + ExpenseStatus, + loading: StatusLoadding, + error: stausError, + } = useExpenseStatus(); + const { + employees, + loading: EmpLoading, + error: EmpError, + } = useEmployeesByProject(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: "", + }; + }) + ); + + 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]); // base64 only, no prefix + reader.onerror = (error) => reject(error); + }); + const removeFile = (index) => { + const newFiles = files.filter((_, i) => i !== index); + setValue("billAttachments", newFiles, { shouldValidate: true }); + }; + + const {mutate:CreateExpense,isPending} = useCreateExpnse(()=>{ + handleClose() + }) + const onSubmit = (payload) => { + console.log("Form Data:", payload); + + CreateExpense(payload) + }; + const ExpenseTypeId = watch("expensesTypeId"); + + useEffect(() => { + setExpenseType(ExpenseTypes?.find((type) => type.id === ExpenseTypeId)); + }, [ExpenseTypeId]); + + const handleClose =()=>{ + reset() + closeModal() + } + return ( +
+
Create New Expense
+
+
+
+ + + {errors.projectId && ( + {errors.projectId.message} + )} +
+ +
+ + + {errors.expensesTypeId && ( + + {errors.expensesTypeId.message} + + )} +
+
+ +
+
+ + + {errors.paymentModeId && ( + + {errors.paymentModeId.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.map((file, idx) => ( + +
+ + {file.fileName} + + + {formatFileSize(file.fileSize)} + +
+ removeFile(idx)} + > +
+ ))} +
+ )} + {Array.isArray(errors.billAttachments) && + errors.billAttachments.map((fileError, index) => ( +
+ {fileError?.fileSize?.message || + fileError?.contentType?.message} +
+ ))} +
+
+ +
+ {" "} + + +
+
+
+ ); +}; + +export default CreateExpense; + diff --git a/src/components/Expenses/ExpenseList.jsx b/src/components/Expenses/ExpenseList.jsx new file mode 100644 index 00000000..89ae489f --- /dev/null +++ b/src/components/Expenses/ExpenseList.jsx @@ -0,0 +1,250 @@ +import React, { useState } from "react"; +import { useExpenseList } from "../../hooks/useExpense"; +import Avatar from "../common/Avatar"; +import { useExpenseContext } from "../../pages/Expense/ExpensePage"; +import { formatDate, formatUTCToLocalTime } from "../../utils/dateUtils"; + +const ExpenseList = () => { + const { setViewExpense } = useExpenseContext(); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const filter = { + projectIds: [], + statusIds: [], + createdByIds: [], + paidById: [], + startDate: null, + endDate: null, + }; + + const { data, isLoading, isError } = useExpenseList(5, currentPage, filter); + + if (isLoading) return
Loading...
; + const items = data ?? []; + const totalPages = data?.totalPages ?? 1; + const hasMore = currentPage < totalPages; + + const paginate = (page) => { + if (page >= 1 && page <= totalPages) { + setCurrentPage(page); + } + }; + return ( +
+
+
+ + + + + + + + + + + + + + {isLoading && ( + + + + )} + + {!isLoading && items.length === 0 && ( + + + + )} + + {!isLoading && + items.map((expense) => ( + + + + + + + + + + ))} + +
+
Date Time
+
+
Expense Type
+
+
Payment Mode
+
+
Paid By
+
+ Amount + + Status + + Action +
+ Loading... +
+ No expenses found. +
+
+ {formatUTCToLocalTime(expense.createdAt)} +
+
+ {expense.expensesType?.name || "N/A"} + + {expense.paymentMode?.name || "N/A"} + +
+ + + {`${expense.paidBy?.firstName ?? ""} ${ + expense.paidBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+
{expense.amount} + + {expense.status?.name || "Unknown"} + + + + setViewExpense({ expenseId: expense.id, view: true }) + } + > + + +
+
+ {!isLoading && items.length > 0 && totalPages > 1 && ( + + )} +
+
+ ); +}; + +export default ExpenseList; diff --git a/src/components/Expenses/ExpenseSchema.js b/src/components/Expenses/ExpenseSchema.js new file mode 100644 index 00000000..56187469 --- /dev/null +++ b/src/components/Expenses/ExpenseSchema.js @@ -0,0 +1,69 @@ +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().min(1, { message: "File data is required" }), + contentType: z.string().refine((val) => ALLOWED_TYPES.includes(val), { + message: "Only PDF, PNG, JPG, or JPEG files are allowed", + }), + fileSize: z.number().max(MAX_FILE_SIZE, { + message: "File size must be less than or equal to 5MB", + }), + description: z.string().optional(), + }) + ) + .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"], + }); + } + }); +}; diff --git a/src/components/Expenses/ViewExpense.jsx b/src/components/Expenses/ViewExpense.jsx new file mode 100644 index 00000000..a2849ede --- /dev/null +++ b/src/components/Expenses/ViewExpense.jsx @@ -0,0 +1,14 @@ +import React from 'react' +import { useExpense } from '../../hooks/useExpense' + +const ViewExpense = ({ExpenseId}) => { + console.log(ExpenseId) + const {} = useExpense(ExpenseId) + return ( +
+ +
+ ) +} + +export default ViewExpense \ No newline at end of file diff --git a/src/data/menuData.json b/src/data/menuData.json index bbd37e2c..d4b2104d 100644 --- a/src/data/menuData.json +++ b/src/data/menuData.json @@ -66,6 +66,12 @@ "available": true, "link": "/gallary" }, + { + "text": "Expense", + "icon": "bx bx-receipt", + "available": true, + "link": "/expenses" + }, { "text": "Administration", "icon": "bx bx-box", diff --git a/src/hooks/masterHook/useMaster.js b/src/hooks/masterHook/useMaster.js index 5d05ab3b..0813469f 100644 --- a/src/hooks/masterHook/useMaster.js +++ b/src/hooks/masterHook/useMaster.js @@ -93,6 +93,76 @@ export const useContactTags = () => { return { contactTags, loading, error }; }; + +export const useExpenseType =()=>{ +const { + data: ExpenseTypes = [], + isLoading: loading, + error, + } = useQuery({ + queryKey: ["Expense Type"], + queryFn: async () => { + const res = await MasterRespository.getExpenseType() + return res.data; + }, + onError: (error) => { + showToast( + error?.response?.data?.message || + error.message || + "Failed to fetch Expense Type", + "error" + ); + }, + }); + + return { ExpenseTypes, loading, error }; +} +export const usePaymentMode =()=>{ +const { + data: PaymentModes = [], + isLoading: loading, + error, + } = useQuery({ + queryKey: ["Payment Mode"], + queryFn: async () => { + const res = await MasterRespository.getPaymentMode() + return res.data; + }, + onError: (error) => { + showToast( + error?.response?.data?.message || + error.message || + "Failed to fetch Payment Mode", + "error" + ); + }, + }); + + return { PaymentModes, loading, error }; +} +export const useExpenseStatus =()=>{ +const { + data: ExpenseStatus = [], + isLoading: loading, + error, + } = useQuery({ + queryKey: ["Expense Status"], + queryFn: async () => { + const res = await MasterRespository.getExpenseStatus() + return res.data; + }, + onError: (error) => { + showToast( + error?.response?.data?.message || + error.message || + "Failed to fetch Expense Status", + "error" + ); + }, + }); + + return { ExpenseStatus, loading, error }; +} // ===Application Masters Query================================================= const fetchMasterData = async (masterType) => { diff --git a/src/hooks/useExpense.js b/src/hooks/useExpense.js new file mode 100644 index 00000000..edaa1a95 --- /dev/null +++ b/src/hooks/useExpense.js @@ -0,0 +1,68 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import ExpenseRepository from "../repositories/ExpsenseRepository" +import showToast from "../services/toastService" +import { queryClient } from "../layouts/AuthLayout"; + + + +// -------------------Query------------------------------------------------------ +export const useExpenseList=( pageSize, pageNumber, filter ) =>{ + +return useQuery({ + queryKey: ["expenses", pageNumber, pageSize, filter], + queryFn: async() => + await ExpenseRepository.GetExpenseList( pageSize, pageNumber, filter ).then((res) => res.data), + keepPreviousData: true, + }); +} + +export const useExpense =(ExpenseId)=>{ + return useQuery({ + queryKey:["Expense",ExpenseId], + queryFn:async()=>await ExpenseRepository.GetExpenseDetails(ExpenseId) + }) +} + + + + +// ---------------------------Mutation--------------------------------------------- + +export const useCreateExpnse =(onSuccessCallBack)=>{ + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async(payload)=>{ + await ExpenseRepository.CreateExpense(payload) + }, + onSuccess:(_,variables)=>{ + showToast("Expense Created Successfully","success") + queryClient.invalidateQueries({queryKey:["expenses"]}) + if (onSuccessCallBack) onSuccessCallBack(); + }, + onError:(error)=>{ + showToast(error.message || "Something went wrong please try again !","success") + } + }) +} +const demoExpense = { + projectId: "proj_123", + expensesTypeId: "1", // corresponds to Travel + paymentModeId: "pm_456", + paidById: "emp_789", + transactionDate: "2025-07-21", + transactionId: "TXN-001234", + description: "Taxi fare from airport to hotel", + location: "New York", + supplerName: "City Taxi Service", + amount: 45.50, + noOfPersons: 2, + billAttachments: [ + { + fileName: "receipt.pdf", + base64Data: "JVBERi0xLjQKJcfs...", // truncated base64 example string + contentType: "application/pdf", + fileSize: 450000, // less than 5MB + description: "Taxi receipt", + }, + ], +}; diff --git a/src/pages/Expense/ExpensePage.jsx b/src/pages/Expense/ExpensePage.jsx new file mode 100644 index 00000000..64a1daaa --- /dev/null +++ b/src/pages/Expense/ExpensePage.jsx @@ -0,0 +1,91 @@ +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"; + +export const ExpenseContext = createContext(); +export const useExpenseContext = () => useContext(ExpenseContext); + +const ExpensePage = () => { + const [isNewExpense, setNewExpense] = useState(false); + const [viewExpense, setViewExpense] = useState({ + expenseId: null, + view: false, + }); + + const contextValue = { + setViewExpense, + }; + + return ( + +
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ + + + {isNewExpense && ( + setNewExpense(false)} + > + setNewExpense(false)} /> + + )} + + + + {viewExpense.view && ( + setViewExpense({ + expenseId: null, + view: false, + }) + } + > + + ) } +
+
+ ); +}; + +export default ExpensePage; diff --git a/src/repositories/ExpsenseRepository.jsx b/src/repositories/ExpsenseRepository.jsx new file mode 100644 index 00000000..0371e867 --- /dev/null +++ b/src/repositories/ExpsenseRepository.jsx @@ -0,0 +1,20 @@ +import { api } from "../utils/axiosClient"; + + +const ExpenseRepository = { + GetExpenseList: ( pageSize, pageNumber, filter ) => { + const payloadJsonString = JSON.stringify(filter); + + + + return api.get(`/api/expense/list?pageSize=${pageSize}&pageNumber=${pageNumber}&filter=${payloadJsonString}`); + }, + + GetExpenseDetails:(id)=>api.get(`/api/Expanse/details/${id}`), + CreateExpense:(data)=>api.post("/api/Expense/create",data), + UpdateExpense:(id)=>api.put(`/api/Expanse/edit/${id}`), + DeleteExpense:(id)=>api.delete(`/api/Expanse/edit/${id}`) + +} + +export default ExpenseRepository; diff --git a/src/router/AppRoutes.jsx b/src/router/AppRoutes.jsx index 5ce5b6d4..d89191d8 100644 --- a/src/router/AppRoutes.jsx +++ b/src/router/AppRoutes.jsx @@ -38,6 +38,7 @@ import LegalInfoCard from "../pages/TermsAndConditions/LegalInfoCard"; import ProtectedRoute from "./ProtectedRoute"; import Directory from "../pages/Directory/Directory"; import LoginWithOtp from "../pages/authentication/LoginWithOtp"; +import ExpensePage from "../pages/Expense/ExpensePage"; const router = createBrowserRouter( [ @@ -76,6 +77,7 @@ const router = createBrowserRouter( { path: "/activities/task", element: }, { path: "/activities/reports", element: }, { path: "/gallary", element: }, + { path: "/expenses", element: }, { path: "/masters", element: }, { path: "/help/support", element: }, { path: "/help/docs", element: }, diff --git a/src/utils/appUtils.js b/src/utils/appUtils.js new file mode 100644 index 00000000..eac45ec7 --- /dev/null +++ b/src/utils/appUtils.js @@ -0,0 +1,5 @@ +export const formatFileSize=(bytes)=> { + if (bytes < 1024) return bytes + " B"; + else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"; + else return (bytes / (1024 * 1024)).toFixed(2) + " MB"; +} \ No newline at end of file diff --git a/src/utils/dateUtils.jsx b/src/utils/dateUtils.jsx index 4d6643b6..45aca2f9 100644 --- a/src/utils/dateUtils.jsx +++ b/src/utils/dateUtils.jsx @@ -68,7 +68,7 @@ export const formatNumber = (num) => { return Number.isInteger(num) ? num : num.toFixed(2); }; export const formatUTCToLocalTime = (datetime) =>{ - return moment.utc(datetime).local().format("MMMM DD, YYYY [at] hh:mm A"); + return moment.utc(datetime).local().format("DD MMMM YYYY hh:mm A"); } export const getCompletionPercentage = (completedWork, plannedWork)=> {