Merge branch 'upgrade_Expense' of https://git.marcoaiot.com/admin/marco.pms.web into Recurring_Expense

This commit is contained in:
Kartik Sharma 2025-11-04 15:43:10 +05:30
commit 76fe342c1e
8 changed files with 534 additions and 439 deletions

View File

@ -13,7 +13,7 @@ const ExpenseByProject = () => {
const [viewMode, setViewMode] = useState("Category"); const [viewMode, setViewMode] = useState("Category");
const [chartData, setChartData] = useState({ categories: [], data: [] }); const [chartData, setChartData] = useState({ categories: [], data: [] });
const { ExpenseTypes, loading: typeLoading } = useExpenseCategory(); const { ExpenseCategories, loading: typeLoading } = useExpenseCategory();
const { data: expenseApiData, isLoading } = useExpenseDataByProject( const { data: expenseApiData, isLoading } = useExpenseDataByProject(
projectId, projectId,
@ -133,7 +133,7 @@ const ExpenseByProject = () => {
style={{ maxWidth: "200px" }} style={{ maxWidth: "200px" }}
> >
<option value="">All Types</option> <option value="">All Types</option>
{ExpenseTypes.map((type) => ( {ExpenseCategories?.map((type) => (
<option key={type.id} value={type.id}> <option key={type.id} value={type.id}>
{type.name} {type.name}
</option> </option>

View File

@ -2,7 +2,8 @@ import { useState, useMemo } from "react";
import Avatar from "../common/Avatar"; import Avatar from "../common/Avatar";
import { formatUTCToLocalTime } from "../../utils/dateUtils"; import { formatUTCToLocalTime } from "../../utils/dateUtils";
import Timeline from "../common/TimeLine"; import Timeline from "../common/TimeLine";
import moment from "moment";
import { getColorNameFromHex } from "../../utils/appUtils";
const ExpenseStatusLogs = ({ data }) => { const ExpenseStatusLogs = ({ data }) => {
const [visibleCount, setVisibleCount] = useState(4); const [visibleCount, setVisibleCount] = useState(4);
@ -18,32 +19,33 @@ const ExpenseStatusLogs = ({ data }) => {
[sortedLogs, visibleCount] [sortedLogs, visibleCount]
); );
const timelineData = useMemo(() => { const timelineData = useMemo(() => {
return logsToShow.map((log, index) => ({ return logsToShow.map((log, index) => ({
id: index + 1, id: index + 1,
title: log.nextStatus?.name || "Status Updated", title: log.nextStatus?.name || "Status Updated",
description: log.nextStatus?.description || "", description: log.nextStatus?.description || "",
timeAgo: formatTimeAgo(log.updatedAt), timeAgo: moment.utc(log?.updatedAt).local().fromNow(),
color: log.nextStatus?.color || "primary", color: getColorNameFromHex(log.nextStatus?.color) || "primary",
users: log.updatedBy users: log.updatedBy
? [ ? [
{ {
name: `${log.updatedBy.firstName || ""} ${` log?.updatedBy?.lastName` || ""}`.trim(), firstName: log.updatedBy.firstName || "",
lastName: log?.updatedBy?.lastName || "",
role: log.updatedBy.jobRoleName || "", role: log.updatedBy.jobRoleName || "",
avatar: log.updatedBy.photo || "assets/img/avatars/default.png", avatar: log.updatedBy.photo,
}, },
] ]
: [], : [],
})); }));
}, [logsToShow]) }, [logsToShow]);
const handleShowMore = () => { const handleShowMore = () => {
setVisibleCount((prev) => prev + 4); setVisibleCount((prev) => prev + 4);
}; };
return ( return (
<> <div className="page-min-h overflow-auto">
<div className="row g-2"> {/* <div className="row g-2">
{logsToShow.map((log) => ( {logsToShow.map((log) => (
<div key={log.id} className="col-12 d-flex align-items-start mb-1"> <div key={log.id} className="col-12 d-flex align-items-start mb-1">
<Avatar <Avatar
@ -81,13 +83,11 @@ const timelineData = useMemo(() => {
Show More Show More
</button> </button>
</div> </div>
)} )} */}
<Timeline items={timelineData} /> <Timeline items={timelineData} />
</> </div>
); );
}; };
export default ExpenseStatusLogs; export default ExpenseStatusLogs;

View File

@ -30,7 +30,7 @@ function ManagePaymentRequest({ closeModal, requestToEdit = null }) {
const { profile } = useProfile(); const { profile } = useProfile();
const schema = PaymentRequestSchema(ExpenseTypes); const schema = PaymentRequestSchema(ExpenseCategories);
const { register, control, watch, handleSubmit, setValue, reset, formState: { errors }, } = useForm({ const { register, control, watch, handleSubmit, setValue, reset, formState: { errors }, } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaultPaymentRequest, defaultValues: defaultPaymentRequest,
@ -492,7 +492,7 @@ setValue('payee',profile?.employeeInfo.id)
<button <button
type="reset" type="reset"
disabled={createPending} disabled={createPending || isPending}
onClick={handleClose} onClick={handleClose}
className="btn btn-label-secondary btn-sm mt-3" className="btn btn-label-secondary btn-sm mt-3"
> >
@ -501,9 +501,9 @@ setValue('payee',profile?.employeeInfo.id)
<button <button
type="submit" type="submit"
className="btn btn-primary btn-sm mt-3" className="btn btn-primary btn-sm mt-3"
disabled={createPending} disabled={createPending || isPending}
> >
{createPending {createPending || isPending
? "Please Wait..." ? "Please Wait..."
: requestToEdit : requestToEdit
? "Update" ? "Update"

View File

@ -1,5 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { EXPENSE_DRAFT, EXPENSE_REJECTEDBY, ITEMS_PER_PAGE } from "../../utils/constants"; import {
EXPENSE_DRAFT,
EXPENSE_REJECTEDBY,
ITEMS_PER_PAGE,
} from "../../utils/constants";
import { import {
formatCurrency, formatCurrency,
getColorNameFromHex, getColorNameFromHex,
@ -13,6 +17,7 @@ import { ExpenseTableSkeleton } from "../Expenses/ExpenseSkeleton";
import ConfirmModal from "../common/ConfirmModal"; import ConfirmModal from "../common/ConfirmModal";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import Error from "../common/Error";
const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => { const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
const { setManageRequest, setVieRequest } = usePaymentRequestContext(); const { setManageRequest, setVieRequest } = usePaymentRequestContext();
@ -37,7 +42,8 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
displayField = "Status"; displayField = "Status";
break; break;
case "submittedBy": case "submittedBy":
key = `${item?.createdBy?.firstName ?? ""} ${item.createdBy?.lastName ?? "" key = `${item?.createdBy?.firstName ?? ""} ${
item.createdBy?.lastName ?? ""
}`.trim(); }`.trim();
displayField = "Submitted By"; displayField = "Submitted By";
break; break;
@ -91,7 +97,8 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
label: "Submitted By", label: "Submitted By",
align: "text-start", align: "text-start",
getValue: (e) => getValue: (e) =>
`${e.createdBy?.firstName ?? ""} ${e.createdBy?.lastName ?? "" `${e.createdBy?.firstName ?? ""} ${
e.createdBy?.lastName ?? ""
}`.trim() || "N/A", }`.trim() || "N/A",
customRender: (e) => ( customRender: (e) => (
<div <div
@ -105,7 +112,8 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
lastName={e.createdBy?.lastName} lastName={e.createdBy?.lastName}
/> />
<span className="text-truncate"> <span className="text-truncate">
{`${e.createdBy?.firstName ?? ""} ${e.createdBy?.lastName ?? "" {`${e.createdBy?.firstName ?? ""} ${
e.createdBy?.lastName ?? ""
}`.trim() || "N/A"} }`.trim() || "N/A"}
</span> </span>
</div> </div>
@ -135,7 +143,8 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
align: "text-center", align: "text-center",
getValue: (e) => ( getValue: (e) => (
<span <span
className={`badge bg-label-${getColorNameFromHex(e?.expenseStatus?.color) || "secondary" className={`badge bg-label-${
getColorNameFromHex(e?.expenseStatus?.color) || "secondary"
}`} }`}
> >
{e?.expenseStatus?.name || "Unknown"} {e?.expenseStatus?.name || "Unknown"}
@ -147,7 +156,8 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const debouncedSearch = useDebounce(search, 500); const debouncedSearch = useDebounce(search, 500);
const { data, isLoading, isError, error, isFetching } = usePaymentRequestList( const { data, isLoading, isError, error, isRefetching, refetch } =
usePaymentRequestList(
ITEMS_PER_PAGE, ITEMS_PER_PAGE,
currentPage, currentPage,
filters, filters,
@ -159,12 +169,7 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
const totalPages = data?.data?.totalPages || 1; const totalPages = data?.data?.totalPages || 1;
if (isError) { if (isError) {
return ( return <Error error={error} isFeteching={isRefetching} refetch={refetch} />;
<div className="text-center text-danger py-5">
<p>Failed to load payment requests.</p>
<small>{error?.message || "Something went wrong."}</small>
</div>
);
} }
const header = [ const header = [
"Request ID", "Request ID",
@ -188,13 +193,14 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
const canEditExpense = (paymentRequest) => { const canEditExpense = (paymentRequest) => {
return ( return (
(paymentRequest?.expenseStatus?.id === EXPENSE_DRAFT || (paymentRequest?.expenseStatus?.id === EXPENSE_DRAFT ||
EXPENSE_REJECTEDBY.includes(paymentRequest?.expenseStatus?.id)) && EXPENSE_REJECTEDBY.includes(paymentRequest?.expenseStatus.id)) &&
paymentRequest?.createdBy?.id === SelfId paymentRequest?.createdBy?.id === SelfId
); );
}; };
const canDetetExpense = (request) => { const canDetetExpense = (request) => {
return ( return (
request?.expenseStatus?.id === EXPENSE_DRAFT && request?.createdBy?.id === SelfId request?.expenseStatus?.id === EXPENSE_DRAFT &&
request?.createdBy?.id === SelfId
); );
}; };
@ -282,8 +288,7 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
}) })
} }
></i> ></i>
{canDetetExpense(paymentRequest) && {canEditExpense(paymentRequest) && (
canEditExpense(paymentRequest) && (
<div className="dropdown z-2"> <div className="dropdown z-2">
<button <button
type="button" type="button"
@ -311,10 +316,13 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
> >
<a className="dropdown-item px-2 cursor-pointer py-1"> <a className="dropdown-item px-2 cursor-pointer py-1">
<i className="bx bx-edit text-primary bx-xs me-2"></i> <i className="bx bx-edit text-primary bx-xs me-2"></i>
<span className="align-left ">Modify</span> <span className="align-left ">
Modify
</span>
</a> </a>
</li> </li>
{canDetetExpense(paymentRequest) && (
<li <li
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@ -323,11 +331,15 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
> >
<a className="dropdown-item px-2 cursor-pointer py-1"> <a className="dropdown-item px-2 cursor-pointer py-1">
<i className="bx bx-trash text-danger bx-xs me-2"></i> <i className="bx bx-trash text-danger bx-xs me-2"></i>
<span className="align-left">Delete</span> <span className="align-left">
Delete
</span>
</a> </a>
</li> </li>
)}
</ul> </ul>
</div>)} </div>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -355,7 +367,8 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
{[...Array(totalPages)].map((_, index) => ( {[...Array(totalPages)].map((_, index) => (
<li <li
key={index} key={index}
className={`page-item ${currentPage === index + 1 ? "active" : "" className={`page-item ${
currentPage === index + 1 ? "active" : ""
}`} }`}
> >
<button <button

View File

@ -118,21 +118,22 @@ const ViewPaymentRequest = ({ requestId }) => {
</div> </div>
<div className="row mb-1"> <div className="row mb-1">
<div className="col-12 col-sm-6 col-md-8"> <div className="col-12 col-sm-6 col-md-8">
<div className="col-12 text-start fw-semibold my-2"> <div className="row">
<div className="col-12 text-start fw-semibold mb-2">
{data?.paymentRequestUID} {data?.paymentRequestUID}
</div> </div>
{/* Row 1 */}
<div className="col-md-6 mb-3"> <div className="col-md-6 mb-3">
<div className="d-flex"> <div className="d-block d-md-flex align-items-center">
<label <label
className="form-label me-2 mb-0 fw-semibold text-start" className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }} style={{ minWidth: "130px" }}
> >
Project Name : Project Name:
</label> </label>
<div className="text-muted">{data.project.name}</div> <div className="text-muted">{data?.project?.name || "—"}</div>
</div> </div>
</div> </div>
<div className="col-md-6 mb-3"> <div className="col-md-6 mb-3">
<div className="d-flex"> <div className="d-flex">
<label <label
@ -221,7 +222,8 @@ const ViewPaymentRequest = ({ requestId }) => {
</label> </label>
<span <span
className={`badge bg-label-${ className={`badge bg-label-${
getColorNameFromHex(data?.expenseStatus?.color) || "secondary" getColorNameFromHex(data?.expenseStatus?.color) ||
"secondary"
}`} }`}
> >
{data?.expenseStatus?.name} {data?.expenseStatus?.name}
@ -447,7 +449,8 @@ const ViewPaymentRequest = ({ requestId }) => {
</div> </div>
)} )}
<div className="col-12 mb-3 text-start"> <div className="col-12 mb-3 text-start">
{((nextStatusWithPermission.length > 0 && !isRejectedRequest) || {((nextStatusWithPermission.length > 0 &&
!isRejectedRequest) ||
(isRejectedRequest && isCreatedBy)) && ( (isRejectedRequest && isCreatedBy)) && (
<> <>
<Label className="form-label me-2 mb-0" required> <Label className="form-label me-2 mb-0" required>
@ -492,6 +495,7 @@ const ViewPaymentRequest = ({ requestId }) => {
</> </>
)} )}
</div> </div>
</div>
<div className="col-12 col-sm-6 col-md-4"> <div className="col-12 col-sm-6 col-md-4">
<ExpenseStatusLogs data={data} /> <ExpenseStatusLogs data={data} />
</div> </div>

View File

@ -1,20 +1,80 @@
import React from 'react' import React from "react";
const Error = ({ error = {}, isFeteching=false, refetch, close }) => {
const statusCode = error.statusCode || error?.response?.status || "Error";
const message =
error.message ||
error?.response?.data?.message ||
"Something went wrong. Please try again later.";
const getErrorInfo = (code) => {
if (code >= 500) {
return {
icon: "bx bx-server text-danger",
title: "Internal Server Error",
subtitle: "Our servers are currently experiencing issues.",
};
}
if (code === 404) {
return {
icon: "bx bx-error text-warning",
title: "Page Not Found",
subtitle: "The requested resource could not be found.",
};
}
if (code === 403) {
return {
icon: "bx bx-block text-danger",
title: "Access Denied",
subtitle: "You do not have permission to access this resource.",
};
}
if (code === 401) {
return {
icon: "bx bx-lock text-danger",
title: "Unauthorized",
subtitle: "Please log in to continue.",
};
}
if (code === 0 || code === "ERR_NETWORK") {
return {
icon: "bx bx-wifi-off text-secondary",
title: "Network Error",
subtitle: "Please check your internet connection.",
};
}
return {
icon: "bx bx-error-circle text-danger",
title: "Unexpected Error",
subtitle: "An unknown error occurred.",
};
};
const { icon, title, subtitle } = getErrorInfo(statusCode);
const Error = ({error,close}) => {
console.log(error)
return ( return (
<div className="container text-center py-5"> <div className="container-fluid d-flex flex-column align-items-center justify-content-center text-center py-5">
<h1 className="display-4 fw-bold text-danger">{error.statusCode || error?.response?.status
}</h1>
<h2 className="mb-3">Internal Server Error</h2>
<p className="lead">
{error.message}
</p>
<a href="/" className="btn btn-primary btn-sm mt-3" onClick={()=>close()}>
Go to Home
</a>
</div>
)
}
export default Error <h4 className="mb-2">{title}</h4>
<p className="text-muted mb-2">{subtitle}</p>
{message && (
<p className="lead small text-body-secondary mb-4">{message}</p>
)}
<div class="d-grid gap-2 col-4 mx-auto">
<button
class="btn btn-sm btn-outline-secondary btn-lg"
type="button"
onClick={refetch}
disabled={isFeteching}
>
<i className={`bx bx-refresh ${isFeteching ? "bx-spin":""} me-1`}></i> Retry
</button>
</div>
</div>
);
};
export default Error;

View File

@ -1,15 +1,25 @@
import React from "react"; import React from "react";
import Avatar from "./Avatar";
const Timeline = ({ items = [], transparent = true }) => { const Timeline = ({ items = [], transparent = true }) => {
return ( return (
<ul className={`timeline ${transparent ? "timeline-transparent text-start" : ""}`}> <ul
className={`timeline ${
transparent ? "timeline-transparent text-start" : ""
}`}
>
{items.map((item) => ( {items.map((item) => (
<li <li
key={item.id} key={item.id}
className={`timeline-item ${transparent ? "timeline-item-transparent" : ""}`} className={`timeline-item ${
transparent ? "timeline-item-transparent" : ""
}`}
> >
<span className={`timeline-point timeline-point-${item.color || "primary"}`}></span> <span
className={`timeline-point timeline-point-${
item.color || "primary"
}`}
></span>
<div className="timeline-event"> <div className="timeline-event">
<div className="timeline-header mb-3 d-flex justify-content-between"> <div className="timeline-header mb-3 d-flex justify-content-between">
@ -26,7 +36,14 @@ const Timeline = ({ items = [], transparent = true }) => {
key={i} key={i}
className="badge bg-lighter rounded d-flex align-items-center gap-2 p-2" className="badge bg-lighter rounded d-flex align-items-center gap-2 p-2"
> >
{att.icon && <img src={att.icon} alt="file" width="15" className="me-2" />} {att.icon && (
<img
src={att.icon}
alt="file"
width="15"
className="me-2"
/>
)}
<span className="h6 mb-0">{att.name}</span> <span className="h6 mb-0">{att.name}</span>
</div> </div>
))} ))}
@ -47,17 +64,18 @@ const Timeline = ({ items = [], transparent = true }) => {
height="32" height="32"
/> />
) : ( ) : (
<span className="avatar-initial rounded-circle pull-up bg-light"> <Avatar
{user.name} firstName={user.firstName}
</span> lastName={user.lastName}
/>
)} )}
</li> </li>
))} ))}
</ul> </ul>
{item.users[0]?.role && ( {item.users?.length === 1 && (
<div className="ms-2"> <div className="m-0">
<p className="mb-0 small fw-medium">{item.users[0].name}</p> <p className="mb-0 small fw-medium">{`${item.users[0].firstName} ${item.users[0].lastName}`}</p>
<small>{item.users[0].role}</small> <small>{item.users[0].role}</small>
</div> </div>
)} )}

View File

@ -67,8 +67,8 @@ export const PROCESS_EXPENSE = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
export const EXPENSE_MANAGE = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11"; export const EXPENSE_MANAGE = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
export const EXPENSE_REJECTEDBY = [ export const EXPENSE_REJECTEDBY = [
"d1ee5eec-24b6-4364-8673-a8f859c60729",
"965eda62-7907-4963-b4a1-657fb0b2724b", "965eda62-7907-4963-b4a1-657fb0b2724b",
"d1ee5eec-24b6-4364-8673-a8f859c60729",
]; ];