Compare commits

...

8 Commits

9 changed files with 370 additions and 155 deletions

View File

@ -254,6 +254,11 @@ const CreateExpense = ({closeModal}) => {
)) ))
)} )}
</select> </select>
{errors.paidById && (
<small className="danger-text">
{errors.paidById.message}
</small>
)}
</div> </div>
</div> </div>

View File

@ -19,9 +19,10 @@ const ExpenseList = () => {
startDate: null, startDate: null,
endDate: null, endDate: null,
}; };
const { data, isLoading, isError } = useExpenseList(ITEMS_PER_PAGE, currentPage, filter); const { data, isLoading, isError,isInitialLoading } = useExpenseList(2, currentPage, filter);
if (isLoading) return <div>Loading...</div>; if (isInitialLoading) return <div>Loading...</div>;
const items = data.data ?? []; const items = data.data ?? [];
const totalPages = data?.totalPages ?? 1; const totalPages = data?.totalPages ?? 1;
const hasMore = currentPage < totalPages; const hasMore = currentPage < totalPages;
@ -37,12 +38,13 @@ const ExpenseList = () => {
<div <div
id="DataTables_Table_0_wrapper" id="DataTables_Table_0_wrapper"
className="dataTables_wrapper no-footer" className="dataTables_wrapper no-footer"
> >
<table <table
className="datatables-users table border-top dataTable no-footer dtr-column text-nowrap" className="datatables-users table border-top dataTable no-footer dtr-column text-nowrap"
id="DataTables_Table_0"
aria-describedby="DataTables_Table_0_info" aria-describedby="DataTables_Table_0_info"
style={{ width: "100%" }} id="horizontal-example"
> >
<thead> <thead>
<tr> <tr>
@ -131,7 +133,7 @@ const ExpenseList = () => {
</tr> </tr>
)} )}
{!isLoading && items.length === 0 && ( {!isInitialLoading && items.length === 0 && (
<tr> <tr>
<td colSpan={7} className="text-center py-3"> <td colSpan={7} className="text-center py-3">
No expenses found. No expenses found.
@ -139,12 +141,12 @@ const ExpenseList = () => {
</tr> </tr>
)} )}
{!isLoading && {!isInitialLoading &&
items.map((expense) => ( items.map((expense) => (
<tr key={expense.id} className="odd"> <tr key={expense.id} className="odd">
<td className="sorting_1" colSpan={2}> <td className="sorting_1" colSpan={2}>
<div className="d-flex justify-content-start align-items-center user-name ms-6"> <div className="d-flex justify-content-start align-items-center user-name ms-6">
<span>{formatUTCToLocalTime(expense.createdAt)}</span> <span>{formatUTCToLocalTime(expense.transactionDate)}</span>
</div> </div>
</td> </td>
<td className="text-start d-none d-sm-table-cell ms-5"> <td className="text-start d-none d-sm-table-cell ms-5">
@ -168,7 +170,7 @@ const ExpenseList = () => {
</span> </span>
</div> </div>
</td> </td>
<td className="d-none d-md-table-cell"><i className='bx bx-rupee b-xs'></i>{expense.amount}</td> <td className="d-none d-md-table-cell text-end"><i className='bx bx-rupee b-xs'></i>{expense.amount}</td>
<td> <td>
<span <span
style={{ style={{
@ -183,22 +185,37 @@ const ExpenseList = () => {
{expense.status?.name || "Unknown"} {expense.status?.name || "Unknown"}
</span> </span>
</td> </td>
<td> <td >
<span <div className="d-flex justify-content-center align-items-center gap-2">
<span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setViewExpense({ expenseId: expense, view: true }) setViewExpense({ expenseId:expense.id, view: true })
} }
> >
<i className="bx bx-show "></i> <i className="bx bx-show text-primary "></i>
</span> </span>
<span
className="cursor-pointer"
>
<i className='bx bx-edit bx-sm text-secondary'></i>
</span>
<span
className="cursor-pointer"
>
<i className='bx bx-trash bx-sm text-danger' ></i>
</span>
</div>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
{!isLoading && items.length > 0 && ( {!isInitialLoading && items.length > 0 && (
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}

View File

@ -67,3 +67,8 @@ export const ExpenseSchema = (expenseTypes) => {
} }
}); });
}; };
export const ActionSchema = z.object({
comment : z.string().min(1,{message:"Please leave comment"}),
selectedStatus: z.string().min(1, { message: "Please select a status" }),
})

View File

@ -0,0 +1,28 @@
import { useState } from 'react';
const PreviewDocument = ({ imageUrl }) => {
const [loading, setLoading] = useState(true);
return (
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: "50vh" }}>
{loading && (
<div className="text-secondary text-center mb-2">
Loading...
</div>
)}
<img
src={imageUrl}
alt="Full View"
className="img-fluid"
style={{
maxHeight: "100vh",
objectFit: "contain",
display: loading ? "none" : "block",
}}
onLoad={() => setLoading(false)}
/>
</div>
);
};
export default PreviewDocument;

View File

@ -1,11 +1,16 @@
import React from "react"; import React, { useState } from "react";
import { useActionOnExpense } from "../../hooks/useExpense"; import { useActionOnExpense, useExpense } from "../../hooks/useExpense";
import { formatUTCToLocalTime } from "../../utils/dateUtils"; import { formatUTCToLocalTime } from "../../utils/dateUtils";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ActionSchema } from "./ExpenseSchema"; import { ActionSchema } from "./ExpenseSchema";
import { useExpenseContext } from "../../pages/Expense/ExpensePage";
const ViewExpense = ({ ExpenseId }) => { const ViewExpense = ({ ExpenseId }) => {
const { data, isLoading, isError, error } = useExpense(ExpenseId);
const [imageLoaded, setImageLoaded] = useState({});
const { setDocumentView } = useExpenseContext();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -31,6 +36,20 @@ const ViewExpense = ({ ExpenseId }) => {
MakeAction(Payload); MakeAction(Payload);
}; };
if (isLoading) {
return (
<div
className="d-flex justify-content-center align-items-center"
style={{ minHeight: "300px" }}
>
<div className="fs-5 text-muted">Loading...</div>
</div>
);
}
const handleImageLoad = (id) => {
setImageLoaded((prev) => ({ ...prev, [id]: true }));
};
return ( return (
<form className="container px-3" onSubmit={handleSubmit(onSubmit)}> <form className="container px-3" onSubmit={handleSubmit(onSubmit)}>
<div className="row mb-3"> <div className="row mb-3">
@ -40,100 +59,175 @@ const ViewExpense = ({ ExpenseId }) => {
</div> </div>
{/* Expense Info Rows */} {/* Expense Info Rows */}
<div className="col-12 col-md-4 mb-3"> <div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex"> <div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Transaction Date :</label> <label className="form-label me-2 mb-0 fw-semibold">
<div className="text-muted">{formatUTCToLocalTime(ExpenseId.transactionDate)}</div> Transaction Date :
</div> </label>
</div>
<div className="col-12 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Expense Type :</label>
<div className="text-muted">{ExpenseId.expensesType.name}</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Supplier :</label>
<div className="text-muted">{ExpenseId.supplerName}</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Amount :</label>
<div className="text-muted"> {ExpenseId.amount}</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Payment Mode :</label>
<div className="text-muted">{ExpenseId.paymentMode.name}</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Paid By :</label>
<div className="text-muted"> <div className="text-muted">
{ExpenseId.paidBy.firstName} {ExpenseId.paidBy.lastName} {formatUTCToLocalTime(data.transactionDate)}
</div> </div>
</div> </div>
</div> </div>
<div className="col-12 col-md-4 mb-3"> <div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
Expense Type :
</label>
<div className="text-muted">{data.expensesType.name}</div>
</div>
</div>
<div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
Supplier :
</label>
<div className="text-muted">{data.supplerName}</div>
</div>
</div>
<div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Amount :</label>
<div className="text-muted"> {data.amount}</div>
</div>
</div>
<div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
Payment Mode :
</label>
<div className="text-muted">{data.paymentMode.name}</div>
</div>
</div>
<div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
Paid By :
</label>
<div className="text-muted">
{data.paidBy.firstName} {data.paidBy.lastName}
</div>
</div>
</div>
<div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">Status :</label> <label className="form-label me-2 mb-0 fw-semibold">Status :</label>
<span className="badge" style={{ backgroundColor: ExpenseId.status.color }}> <span
{ExpenseId.status.displayName} className="badge"
style={{ backgroundColor: data.status.color }}
>
{data.status.displayName}
</span> </span>
</div> </div>
</div> </div>
<div className="col-12 col-md-4 mb-3"> <div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex"> <div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Pre-Approved :</label> <label className="form-label me-2 mb-0 fw-semibold">
<div className="text-muted">{ExpenseId.preApproved ? "Yes" : "No"}</div> Pre-Approved :
</label>
<div className="text-muted">{data.preApproved ? "Yes" : "No"}</div>
</div> </div>
</div> </div>
<div className="col-12 col-md-4 mb-3"> <div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex"> <div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Project :</label> <label className="form-label me-2 mb-0 fw-semibold">
<div className="text-muted text-start">{ExpenseId.project.name}</div> Project :
</label>
<div className="text-muted text-start">{data.project.name}</div>
</div> </div>
</div> </div>
<div className="col-12 col-md-4 mb-3"> <div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex"> <div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Created By :</label> <label className="form-label me-2 mb-0 fw-semibold">
Created By :
</label>
<div className="text-muted"> <div className="text-muted">
{ExpenseId.createdBy.firstName} {ExpenseId.createdBy.lastName} {data.createdBy.firstName} {data.createdBy.lastName}
</div> </div>
</div> </div>
</div> </div>
<div className="col-12 col-md-4 mb-3"> <div className="col-12 col-sm-6 col-md-4 mb-3">
<div className="d-flex"> <div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">Created At :</label> <label className="form-label me-2 mb-0 fw-semibold">
<div className="text-muted">{formatUTCToLocalTime(ExpenseId.createdAt, true)}</div> Created At :
</label>
<div className="text-muted">
{formatUTCToLocalTime(data.createdAt, true)}
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="text-start"> <div className="text-start">
<label className="form-label me-2 mb-0 fw-semibold">Description:</label> <label className="form-label me-2 mb-0 fw-semibold">Description:</label>
<div className="text-muted"> <div className="text-muted">{data.description}</div>
Local travel reimbursement for delivery of materials to client site via City Taxi Service </div>
</div> <div className="col-12 my-2 text-start ">
<label className="form-label me-2 mb-0 fw-semibold">
Attachement :
</label>
{data.documents?.map((doc) => (
<a
className="list-group-item list-group-item-action d-flex align-items-center"
key={doc.id}
>
<div
className="rounded me-3 d-flex align-items-center justify-content-center"
style={{ height: "50px", width: "80px", position: "relative" }}
>
{doc.contentType === "application/pdf" ? (
<i className="bx bxs-file-pdf"></i>
) : (
<>
{!imageLoaded[doc.id] && (
<div className="position-absolute text-secondary">
Loading...
</div>
)}
<img
src={doc.thumbPreSignedUrl}
alt={doc.fileName}
className="img-fluid rounded"
style={{
maxHeight: "100%",
maxWidth: "100%",
objectFit: "cover",
opacity: imageLoaded[doc.id] ? 1 : 0,
transition: "opacity 0.3s ease-in-out",
}}
onLoad={() => handleImageLoad(doc.id)}
onClick={() =>
setDocumentView({ IsOpen: true, Image: doc.preSignedUrl })
}
/>
</>
)}
</div>
<div className="w-100">
<p className="mb-0">{doc.fileName}</p>
<div className="d">
<i className="bx bx-cloud-download cursor-pointer"></i>
</div>
</div>
</a>
))}
</div> </div>
<hr className="divider my-1" /> <hr className="divider my-1" />
{Array.isArray(ExpenseId.nextStatus) && ExpenseId.nextStatus.length > 0 && ( {Array.isArray(data.nextStatus) && data.nextStatus.length > 0 && (
<div className="col-12 mb-3 text-start"> <div className="col-12 mb-3 text-start">
<label className="form-label me-2 mb-0 fw-semibold">Comment:</label> <label className="form-label me-2 mb-0 fw-semibold">Comment:</label>
<textarea <textarea
@ -148,7 +242,7 @@ const ViewExpense = ({ ExpenseId }) => {
<input type="hidden" {...register("selectedStatus")} /> <input type="hidden" {...register("selectedStatus")} />
<div className="d-flex flex-wrap gap-2 my-2"> <div className="d-flex flex-wrap gap-2 my-2">
{ExpenseId.nextStatus.map((status, index) => ( {data.nextStatus.map((status, index) => (
<button <button
key={index} key={index}
type="button" type="button"

View File

@ -68,6 +68,21 @@ useEffect(() => {
backgroundColor: 'rgba(0,0,0,0.3)', backgroundColor: 'rgba(0,0,0,0.3)',
}; };
const getPositionClass = (type) => {
switch (type) {
case 'top':
return 'mt-3';
case 'bottom':
return 'mt-auto mb-0 d-flex flex-column';
case 'left':
return 'position-absolute top-50 start-0 translate-middle-y me-auto';
case 'right':
return 'position-absolute top-50 end-0 translate-middle-y ms-auto';
case 'center':
default:
return 'modal-dialog-centered';
}
};
return ( return (
<div <div
@ -80,7 +95,7 @@ useEffect(() => {
{...dataAttributesProps} {...dataAttributesProps}
style={backdropStyle} style={backdropStyle}
> >
<div className={`modal-dialog ${dialogClass} ${modalSizeClass } mx-sm-auto mx-1`} role={role} > <div className={`modal-dialog ${modalSizeClass} ${getPositionClass(modalType)} ${dialogClass} mx-sm-auto mx-1`} role={role}>
<div className="modal-content"> <div className="modal-content">
<div className="modal-header p-0"> <div className="modal-header p-0">
{/* Close button inside the modal header */} {/* Close button inside the modal header */}

View File

@ -1,68 +1,95 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import ExpenseRepository from "../repositories/ExpsenseRepository" import ExpenseRepository from "../repositories/ExpsenseRepository";
import showToast from "../services/toastService" import showToast from "../services/toastService";
import { queryClient } from "../layouts/AuthLayout"; import { queryClient } from "../layouts/AuthLayout";
// -------------------Query------------------------------------------------------ // -------------------Query------------------------------------------------------
export const useExpenseList=( pageSize, pageNumber, filter ) =>{ export const useExpenseList = (pageSize, pageNumber, filter) => {
return useQuery({
return useQuery({ queryKey: ["Expenses", pageNumber, pageSize, filter],
queryKey: ["expenses", pageNumber, pageSize, filter], queryFn: async () =>
queryFn: async() => await ExpenseRepository.GetExpenseList(pageSize, pageNumber, filter).then(
await ExpenseRepository.GetExpenseList( pageSize, pageNumber, filter ).then((res) => res.data), (res) => res.data
),
keepPreviousData: true, keepPreviousData: true,
}); });
} };
export const useExpense =(ExpenseId)=>{
return useQuery({
queryKey:["Expense",ExpenseId],
queryFn:async()=>await ExpenseRepository.GetExpenseDetails(ExpenseId)
})
}
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(
(res) => res.data
),
enabled: !!ExpenseId,
});
};
// ---------------------------Mutation--------------------------------------------- // ---------------------------Mutation---------------------------------------------
export const useCreateExpnse =(onSuccessCallBack)=>{ export const useCreateExpnse = (onSuccessCallBack) => {
const queryClient = useQueryClient() const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async(payload)=>{ mutationFn: async (payload) => {
await ExpenseRepository.CreateExpense(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",
}, },
], 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 !",
"error"
);
},
});
};
export const useActionOnExpense = (onSuccessCallBack) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload) => {
const response = await ExpenseRepository.ActionOnExpense(payload);
return response.data;
},
onSuccess: (updatedExpense, variables) => {
showToast("Expense updated successfully", "success");
queryClient.setQueriesData(
{
queryKey: ["expenses"],
exact: false,
},
(oldData) => {
if (!oldData) return oldData;
return {
...oldData,
data: oldData.data.map((item) =>
item.id === updatedExpense.id
? {
...item,
nextStatus: updatedExpense.nextStatus,
status: updatedExpense.status,
}
: item
),
};
}
);
if (onSuccessCallBack) onSuccessCallBack();
},
onError: (error) => {
showToast(
error.message || "Something went wrong, please try again!",
"error"
);
},
});
}; };

View File

@ -5,6 +5,7 @@ import CreateExpense from "../../components/Expenses/CreateExpense";
import ViewExpense from "../../components/Expenses/ViewExpense"; import ViewExpense from "../../components/Expenses/ViewExpense";
import Breadcrumb from "../../components/common/Breadcrumb"; import Breadcrumb from "../../components/common/Breadcrumb";
import GlobalModel from "../../components/common/GlobalModel"; import GlobalModel from "../../components/common/GlobalModel";
import PreviewDocument from "../../components/Expenses/PreviewDocument";
export const ExpenseContext = createContext(); export const ExpenseContext = createContext();
export const useExpenseContext = () => useContext(ExpenseContext); export const useExpenseContext = () => useContext(ExpenseContext);
@ -15,9 +16,14 @@ const ExpensePage = () => {
expenseId: null, expenseId: null,
view: false, view: false,
}); });
const [ViewDocument, setDocumentView] = useState({
IsOpen: false,
Image: null,
});
const contextValue = { const contextValue = {
setViewExpense, setViewExpense,
setDocumentView,
}; };
return ( return (
@ -46,11 +52,15 @@ const ExpensePage = () => {
<div className="col-7 col-sm-8 text-end gap-2"> <div className="col-7 col-sm-8 text-end gap-2">
<button <button
type="button" type="button"
className="btn btn-sm btn-primary me-2" data-bs-toggle="tooltip"
data-bs-offset="0,8"
data-bs-placement="top"
data-bs-custom-class="tooltip"
title="Add New Expense"
className={`p-1 me-2 bg-primary rounded-circle `}
onClick={() => setNewExpense(true)} onClick={() => setNewExpense(true)}
> >
<span className="icon-base bx bx-plus-circle me-1"></span> <i className="bx bx-plus fs-4 text-white"></i>
Add New
</button> </button>
</div> </div>
</div> </div>
@ -59,30 +69,42 @@ const ExpensePage = () => {
<ExpenseList /> <ExpenseList />
{isNewExpense && ( {isNewExpense && (
<GlobalModel <GlobalModel
isOpen={isNewExpense} isOpen={isNewExpense}
size="lg" size="lg"
closeModal={() => setNewExpense(false)} closeModal={() => setNewExpense(false)}
> >
<CreateExpense closeModal={() => setNewExpense(false)} /> <CreateExpense closeModal={() => setNewExpense(false)} />
</GlobalModel> </GlobalModel>
)} )}
{viewExpense.view && (
<GlobalModel
isOpen={viewExpense.view}
size="lg"
modalType="top"
closeModal={() =>
setViewExpense({
expenseId: null,
view: false,
})
}
>
<ViewExpense ExpenseId={viewExpense.expenseId} />
</GlobalModel>
)}
{viewExpense.view && (<GlobalModel {ViewDocument.IsOpen && (
isOpen={viewExpense.view} <GlobalModel
size="lg" size="lg"
closeModal={() => m
setViewExpense({ isOpen={ViewDocument.IsOpen}
expenseId: null, closeModal={() => setDocumentView({ IsOpen: false, Image: null })}
view: false, >
}) <PreviewDocument imageUrl={ViewDocument.Image} />
} </GlobalModel>
> )}
<ViewExpense ExpenseId={viewExpense.expenseId}/>
</GlobalModel>) }
</div> </div>
</ExpenseContext.Provider> </ExpenseContext.Provider>
); );

View File

@ -17,7 +17,9 @@ const MasterTable = ({ data, columns, loading, handleModalData }) => {
"checkLists", "checkLists",
"isSystem", "isSystem",
"isActive", "isActive",
"noOfPersonsRequired" "noOfPersonsRequired",
"color",
"displayName"
]; ];
const safeData = Array.isArray(data) ? data : []; const safeData = Array.isArray(data) ? data : [];