+
+ {errors.description && (
+
+ {errors.description.message}
+
+ )}
@@ -236,7 +389,6 @@ const removeFile = (index) => {
{
style={{ cursor: "pointer" }}
onClick={() => document.getElementById("billAttachments").click()}
>
-
+
Click to select or click here to browse
+ (PDF, JPG, PNG, max 5MB)
{
multiple
style={{ display: "none" }}
{...register("billAttachments")}
- onChange={(e) => {
- onFileChange(e);
- e.target.value = ""; // ← this line resets the file input
- }}
+ onChange={(e) => {
+ onFileChange(e);
+ e.target.value = "";
+ }}
/>
+ {errors.billAttachments && (
+
+ {errors.billAttachments.message}
+
+ )}
{files.length > 0 && (
@@ -270,33 +428,38 @@ const removeFile = (index) => {
key={idx}
class="d-flex justify-content-between text-start p-1"
>
-
-
{file.fileName}
-
- {formatFileSize(file.fileSize
-)}
-
+
+
+ {file.fileName}
+
+
+ {formatFileSize(file.fileSize)}
+
-
removeFile(idx)}>
+
removeFile(idx)}
+ >
))}
)}
-{Array.isArray(errors.billAttachments) &&
- errors.billAttachments.map((fileError, index) => (
-
- {fileError?.fileSize?.message || fileError?.contentType?.message}
-
- ))}
+ {Array.isArray(errors.billAttachments) &&
+ errors.billAttachments.map((fileError, index) => (
+
+ {fileError?.fileSize?.message ||
+ fileError?.contentType?.message}
+
+ ))}
{" "}
-
@@ -306,3 +469,4 @@ const removeFile = (index) => {
};
export default CreateExpense;
+
diff --git a/src/components/Expenses/ExpenseList.jsx b/src/components/Expenses/ExpenseList.jsx
index 8fc54a7e..89ae489f 100644
--- a/src/components/Expenses/ExpenseList.jsx
+++ b/src/components/Expenses/ExpenseList.jsx
@@ -1,98 +1,250 @@
-import React from 'react'
+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 (
-
-
-
+
+
+
+
+
+ |
+ Date Time
+ |
+
+ Expense Type
+ |
+
+ Payment Mode
+ |
+
+ Paid By
+ |
+
+ Amount
+ |
+
+ Status
+ |
+
+ Action
+ |
+
+
+
+ {isLoading && (
+
+ |
+ Loading...
+ |
+
+ )}
-
+ |
+ No expenses found.
+ |
+
+ )}
+
+ {!isLoading &&
+ items.map((expense) => (
+
+ |
+
+ {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 && (
+
-
-
-
- )
-}
+ {index + 1}
+
+
+ ))}
-export default ExpenseList
\ No newline at end of file
+
+ paginate(currentPage + 1)}
+ >
+ »
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default ExpenseList;
diff --git a/src/components/Expenses/ExpenseSchema.js b/src/components/Expenses/ExpenseSchema.js
index 1b5f4bb1..56187469 100644
--- a/src/components/Expenses/ExpenseSchema.js
+++ b/src/components/Expenses/ExpenseSchema.js
@@ -1,66 +1,69 @@
-import { z } from 'zod';
+import { z } from "zod";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = [
- 'application/pdf',
- 'image/png',
- 'image/jpg',
- 'image/jpeg',
+ "application/pdf",
+ "image/png",
+ "image/jpg",
+ "image/jpeg",
];
-export const ExpenseSchema = 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(), // if optional, else use .min(1)
- 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.number().min(1, { message: "Amount must be at least 1" }).max(10000, { message: "Amount must not exceed 10,000" }),
- noOfPersons: z.number().min(1, { message: "1 Employee at least required" }),
- statusId: z.string().min(1, { message: "Please select status" }),
- 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",
+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",
}),
- fileSize: z.number().max(MAX_FILE_SIZE, {
- message: "File size must be less than or equal to 5MB",
- }),
- description: z.string().optional(),
- })
+ 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"],
+ }
)
- .nonempty({ message: "At least one file attachment is required" }),
-});
-
-
-let payload=
-{
-"projectId": "2618f2ef-2823-11f0-9d9e-bc241163f504",
-"expensesTypeId": "dd120bc4-ab0a-45ba-8450-5cd45ff221ca",
-"paymentModeId": "ed667353-8eea-4fd1-8750-719405932480",
-"paidById": "08dda7d8-014e-443f-858d-a55f4b484bc4",
-"transactionDate": "2025-07-12T09:56:54.122Z",
-"transactionId": "string",
-"description": "string",
-"location": "string",
-"supplerName": "string",
-"amount": 390,
-"noOfPersons": 0,
-"statusId": "297e0d8f-f668-41b5-bfea-e03b354251c8",
-"billAttachments": [
-{
-"fileName": "string",
-"base64Data": "string",
-"contentType": "string",
-"fileSize": 0,
-"description": "string"
-}
-]
-}
\ No newline at end of file
+ .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/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
index a2f72cc6..edaa1a95 100644
--- a/src/hooks/useExpense.js
+++ b/src/hooks/useExpense.js
@@ -1,30 +1,68 @@
-import { useMutation } from "@tanstack/react-query"
+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 = ()=>{
+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 =()=>{
+export const useCreateExpnse =(onSuccessCallBack)=>{
+ const queryClient = useQueryClient()
return useMutation({
- mutationFn:(payload)=>{
+ 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")
}
})
-}
\ No newline at end of file
+}
+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
index ca511bbd..64a1daaa 100644
--- a/src/pages/Expense/ExpensePage.jsx
+++ b/src/pages/Expense/ExpensePage.jsx
@@ -1,49 +1,90 @@
-import React, { useState } from "react";
+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 CreateExpense from "../../components/Expenses/createExpense";
+
+export const ExpenseContext = createContext();
+export const useExpenseContext = () => useContext(ExpenseContext);
const ExpensePage = () => {
- const[IsNewExpen,setNewExpense] = useState(false)
+ const [isNewExpense, setNewExpense] = useState(false);
+ const [viewExpense, setViewExpense] = useState({
+ expenseId: null,
+ view: false,
+ });
+
+ const contextValue = {
+ setViewExpense,
+ };
return (
-
-
-
-
-
-
-
-
-
-
-
-
setNewExpense(true)}>
- Add New
-
+
+
+
+
+
+
+
+
+
+
+
+ setNewExpense(true)}
+ >
+
+ Add New
+
+
-
-
- setNewExpense(false)}>
-
-
-
+
+
+ {isNewExpense && (
+
setNewExpense(false)}
+ >
+ setNewExpense(false)} />
+
+ )}
+
+
+
+ {viewExpense.view && (
+ setViewExpense({
+ expenseId: null,
+ view: false,
+ })
+ }
+ >
+
+ ) }
+
+
);
};
diff --git a/src/repositories/ExpsenseRepository.jsx b/src/repositories/ExpsenseRepository.jsx
index 03199212..0371e867 100644
--- a/src/repositories/ExpsenseRepository.jsx
+++ b/src/repositories/ExpsenseRepository.jsx
@@ -2,12 +2,19 @@ import { api } from "../utils/axiosClient";
const ExpenseRepository = {
- GetExpenseList:()=>api.get("/api/expanse/list"),
+ 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/Expanse/create",data),
+ 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;
\ No newline at end of file
+export default ExpenseRepository;
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)=> {