Merge branch 'Recurring_Expense' into upgrade_Expense

This commit is contained in:
pramod.mahajan 2025-11-06 00:25:26 +05:30
commit 4ae0b403a6
26 changed files with 1337 additions and 868 deletions

View File

@ -12,7 +12,6 @@ import {
} from "../../utils/constants";
import {
formatCurrency,
formatFigure,
getColorNameFromHex,
useDebounce,
} from "../../utils/appUtils";
@ -167,7 +166,7 @@ const ExpenseList = ({ filters, groupBy = "transactionDate", searchText }) => {
{
key: "amount",
label: "Amount",
getValue: (e) => <>{formatFigure(e?.amount,{type:"currency",currency : e?.currency?.currencyCode ?? "INR"} )}</>,
getValue: (e) => <>{formatCurrency(e?.amount)}</>,
isAlwaysVisible: true,
align: "text-end",
},
@ -296,7 +295,8 @@ const ExpenseList = ({ filters, groupBy = "transactionDate", searchText }) => {
${col.key === "submitted" ? "justify-content-center":""}
`}>{col.customRender
? col.customRender(expense)
: col.getValue(expense)}</div>
: col.getValue(expense)}
</div>
</td>
)
)}
@ -311,7 +311,7 @@ const ExpenseList = ({ filters, groupBy = "transactionDate", searchText }) => {
})
}
></i>
{
{canDetetExpense(expense) &&
canEditExpense(expense) && (
<div className="dropdown z-2">
<button

View File

@ -14,7 +14,7 @@ export const ExpenseSchema = (expenseTypes) => {
return z
.object({
projectId: z.string().min(1, { message: "Project is required" }),
expenseCategoryId: z
expensesCategoryId: z
.string()
.min(1, { message: "Expense type is required" }),
paymentModeId: z.string().min(1, { message: "Payment mode is required" }),

View File

@ -12,7 +12,7 @@ const ExpenseStatusLogs = ({ data }) => {
return [...data.expenseLogs].sort(
(a, b) => new Date(b.updateAt) - new Date(a.updateAt)
);
}, [data?.expenseLogs]);
}, [data?.updateLogs]);
const logsToShow = useMemo(
() => sortedLogs.slice(0, visibleCount),

View File

@ -1,10 +1,9 @@
import React from "react";
import { formatFileSize, getIconByFileType } from "../../utils/appUtils";
import Tooltip from "../common/Tooltip";
const Filelist = ({ files, removeFile, expenseToEdit }) => {
return (
<div className="d-flex flex-wrap gap-2 mt-2">
<div className="d-block">
{files
.filter((file) => {
if (expenseToEdit) {
@ -53,44 +52,3 @@ const Filelist = ({ files, removeFile, expenseToEdit }) => {
};
export default Filelist;
export const FilelistView = ({ files, viewFile }) => {
return (
<div className="d-flex flex-wrap gap-2 mt-2">
{files?.map((file, idx) => (
<div className="col-12 col-sm-6 col-md-4 bg-white " key={idx}>
<div className="row align-items-center">
{/* File icon and info */}
<div className="col-10 d-flex align-items-center gap-2">
<i
className={`bx ${getIconByFileType(file?.fileName)} fs-3`}
style={{ minWidth: "30px" }}
></i>
<div
className="d-flex flex-column text-truncate"
onClick={(e) => {
e.preventDefault();
viewFile({
IsOpen: true,
Image: file.preSignedUrl,
});
}}
>
<span className="fw-medium small text-truncate">
{file.fileName}
</span>
<span className="text-body-secondary small">
<Tooltip text={"Click on file"}>
{" "}
{file.fileSize ? formatFileSize(file.fileSize) : ""}
</Tooltip>
</span>
</div>
</div>
</div>
</div>
))}
</div>
);
};

View File

@ -154,7 +154,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
if (expenseToEdit && data) {
reset({
projectId: data.project.id || "",
expenseCategoryId: data.expensesCategory?.id || "",
expensesCategoryId: data.expensesType.id || "",
paymentModeId: data.paymentMode.id || "",
paidById: data.paidBy.id || "",
transactionDate: data.transactionDate?.slice(0, 10) || "",
@ -254,7 +254,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
<select
className="form-select form-select-sm"
id="expensesCategoryId"
{...register("expenseCategoryId")}
{...register("expensesCategoryId")}
>
<option value="" disabled>
Select Type

View File

@ -44,8 +44,7 @@ const ViewExpense = ({ ExpenseId }) => {
const IsReview = useHasUserPermission(REVIEW_EXPENSE);
const [imageLoaded, setImageLoaded] = useState({});
const { setDocumentView } = useExpenseContext();
const ActionSchema =
ExpenseActionScheam(IsPaymentProcess, data?.createdAt) ?? z.object({});
const ActionSchema = ExpenseActionScheam(IsPaymentProcess,data?.createdAt) ?? z.object({});
const navigate = useNavigate();
const {
register,
@ -98,7 +97,7 @@ const ViewExpense = ({ ExpenseId }) => {
const onSubmit = (formData) => {
const Payload = {
...formData,
reimburseDate: localToUtc(formData.reimburseDate),
reimburseDate:localToUtc(formData.reimburseDate),
expenseId: ExpenseId,
comment: formData.comment,
};

View File

@ -49,7 +49,7 @@ function ManagePaymentRequest({ closeModal, requestToEdit = null }) {
error: ExpenseError,
} = useExpenseCategory();
const { profile } = useProfile();
const {
data: Payees,
isLoading: isPayeeLoaing,

View File

@ -6,7 +6,6 @@ import {
} from "../../utils/constants";
import {
formatCurrency,
formatFigure,
getColorNameFromHex,
useDebounce,
} from "../../utils/appUtils";
@ -132,7 +131,7 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
align: "text-start",
getValue: (e) => (
<>
{formatFigure(e?.amount,{type:"currency",currency : e?.currency?.currencyCode})}
{formatCurrency(e?.amount)}&nbsp;{e.currency.currencyCode}
</>
),
@ -254,7 +253,7 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
<td colSpan={8} className="text-start">
<div className="d-flex align-items-center">
{" "}
<small className="fs-6 py-1 ms-2">
<small className="fs-6 py-1">
{displayField} :{" "}
</small>{" "}
<small className="fs-6 ms-3">
@ -272,9 +271,9 @@ const PaymentRequestList = ({ filters, groupBy = "submittedBy", search }) => {
key={col.key}
className={`d-table-cell ${col.align ?? ""}`}
>
<div className="ms-2"> {col?.customRender
{col?.customRender
? col?.customRender(paymentRequest)
: col?.getValue(paymentRequest)}</div>
: col?.getValue(paymentRequest)}
</td>
)
)}

View File

@ -27,28 +27,34 @@ export const PaymentRequestSchema = (expenseTypes, isItself) => {
.refine((val) => /^\d+(\.\d{1,2})?$/.test(val.toString()), {
message: "Amount must have at most 2 decimal places",
}),
billAttachments: z
.array(
z.object({
fileName: z.string().min(1, { message: "Filename 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),
})
)
.optional(),
});
};
billAttachments: z
.array(
z.object({
fileName: z.string().min(1, { message: "Filename 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),
})
).refine((data)=>{
if(isItself){
payee.z.string().optional();
}
}),
})
};
export const defaultPaymentRequest = {
title: "",
title:"",
description: "",
payee: "",
currencyId: "",
@ -60,6 +66,7 @@ export const defaultPaymentRequest = {
billAttachments: [],
};
export const SearchPaymentRequestSchema = z.object({
projectIds: z.array(z.string()).optional(),
statusIds: z.array(z.string()).optional(),
@ -82,6 +89,7 @@ export const defaultPaymentRequestFilter = {
endDate: null,
};
export const PaymentRequestActionScheam = (
isTransaction = false,
transactionDate
@ -90,7 +98,7 @@ export const PaymentRequestActionScheam = (
.object({
comment: z.string().min(1, { message: "Please leave comment" }),
statusId: z.string().min(1, { message: "Please select a status" }),
paidTransactionId: z.string().nullable().optional(),
paymentRequestId: z.string().nullable().optional(),
paidAt: z.string().nullable().optional(),
paidById: z.string().nullable().optional(),
tdsPercentage: z.string().nullable().optional(),
@ -99,10 +107,10 @@ export const PaymentRequestActionScheam = (
})
.superRefine((data, ctx) => {
if (isTransaction) {
if (!data.paidTransactionId?.trim()) {
if (!data.paymentRequestId?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["paidTransactionId"],
path: ["reimburseTransactionId"],
message: "Reimburse Transaction ID is required",
});
}
@ -113,7 +121,6 @@ export const PaymentRequestActionScheam = (
message: "Transacion Date is required",
});
}
if (!data.paidById) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@ -139,7 +146,7 @@ export const PaymentRequestActionScheam = (
});
};
export const defaultPaymentRequestActionValues = {
export const defaultActionValues = {
comment: "",
statusId: "",
paidTransactionId: null,

View File

@ -1,93 +0,0 @@
import { useState, useMemo } from "react";
import Avatar from "../common/Avatar";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import Timeline from "../common/TimeLine";
import moment from "moment";
import { getColorNameFromHex } from "../../utils/appUtils";
const PaymentStatusLogs = ({ data }) => {
const [visibleCount, setVisibleCount] = useState(4);
const sortedLogs = useMemo(() => {
if (!data?.updateLogs) return [];
return [...data.updateLogs].sort(
(a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)
);
}, [data?.updateLogs]);
const logsToShow = useMemo(
() => sortedLogs.slice(0, visibleCount),
[sortedLogs, visibleCount]
);
const timelineData = useMemo(() => {
return logsToShow.map((log, index) => ({
id: index + 1,
title: log.nextStatus?.name || "Status Updated",
description: log.nextStatus?.description || "",
timeAgo: log.updatedAt,
color: getColorNameFromHex(log.nextStatus?.color) || "primary",
users: log.updatedBy
? [
{
firstName: log.updatedBy.firstName || "",
lastName: log?.updatedBy?.lastName || "",
role: log.updatedBy.jobRoleName || "",
avatar: log.updatedBy.photo,
},
]
: [],
}));
}, [logsToShow]);
const handleShowMore = () => {
setVisibleCount((prev) => prev + 4);
};
return (
<div className="page-min-h overflow-auto">
{/* <div className="row g-2">
{logsToShow.map((log) => (
<div key={log.id} className="col-12 d-flex align-items-start mb-1">
<Avatar
size="xs"
firstName={log.updatedBy.firstName}
lastName={log.updatedBy.lastName}
/>
<div className="flex-grow-1">
<div className="text-start">
<div className="flex">
<span>{`${log.updatedBy.firstName} ${log.updatedBy.lastName}`}</span>
<small className="text-secondary text-tiny ms-2">
<em>{log.action}</em>
</small>
<span className="text-tiny text-secondary d-block">
{formatUTCToLocalTime(log.updateAt, true)}
</span>
</div>
<div className="d-flex align-items-center text-muted small mt-1">
<span>{log.comment}</span>
</div>
</div>
</div>
</div>
))}
</div>
{sortedLogs.length > visibleCount && (
<div className="text-center my-1">
<button
className="btn btn-xs btn-outline-primary"
onClick={handleShowMore}
>
Show More
</button>
</div>
)} */}
<Timeline items={timelineData} />
</div>
);
};
export default PaymentStatusLogs;

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
useActionOnExpense,
useActionOnPaymentRequest,
@ -29,18 +29,11 @@ import { useNavigate } from "react-router-dom";
import { usePaymentRequestContext } from "../../pages/PaymentRequest/PaymentRequestPage";
import { useHasUserPermission } from "../../hooks/useHasUserPermission";
import {
EXPENSE_DRAFT,
EXPENSE_REJECTEDBY,
PROCESS_EXPENSE,
REVIEW_EXPENSE,
} from "../../utils/constants";
import Label from "../common/Label";
import {
defaultPaymentRequestActionValues,
PaymentRequestActionScheam,
} from "./PaymentRequestSchema";
import PaymentStatusLogs from "./PaymentStatusLogs";
import { FilelistView } from "../Expenses/Filelist";
const ViewPaymentRequest = ({ requestId }) => {
const { data, isLoading, isError, error, isFetching } =
@ -53,8 +46,7 @@ const ViewPaymentRequest = ({ requestId }) => {
const { setDocumentView, setModalSize, setVieRequest, setIsExpenseGenerate } =
usePaymentRequestContext();
const ActionSchema =
PaymentRequestActionScheam(IsPaymentProcess, data?.createdAt) ??
z.object({});
ExpenseActionScheam(IsPaymentProcess, data?.createdAt) ?? z.object({});
const navigate = useNavigate();
const {
register,
@ -65,7 +57,7 @@ const ViewPaymentRequest = ({ requestId }) => {
formState: { errors },
} = useForm({
resolver: zodResolver(ActionSchema),
defaultValues: defaultPaymentRequestActionValues,
defaultValues: defaultActionValues,
});
const userPermissions = useSelector(
@ -107,7 +99,7 @@ const ViewPaymentRequest = ({ requestId }) => {
const onSubmit = (formData) => {
const Payload = {
...formData,
paidAt: localToUtc(formData.paidAt),
paidAt: localToUtc(formData.reimburseDate),
paymentRequestId: data.id,
comment: formData.comment,
};
@ -130,22 +122,15 @@ const ViewPaymentRequest = ({ requestId }) => {
>
<div className="col-12 mb-2 text-center ">
<h5 className="fw-semibold m-0">Payment Request Details</h5>
<hr />
</div>
<div className="row text-start">
<div className=" col-sm-12 col-md-7 border-none border-md-end">
<div className="row mb-1">
<div className="col-12 col-sm-6 col-md-8">
<div className="row">
<div className="col-12 d-flex justify-content-between text-start fw-semibold mb-2">
<div className="col-12 text-start fw-semibold mb-2">
{data?.paymentRequestUID}
<span
className={`badge bg-label-${
getColorNameFromHex(data?.expenseStatus?.color) || "secondary"
}`}
>
{data?.expenseStatus?.name}
</span>
</div>
<div className="col-md-12 mb-3">
<div className="col-md-6 mb-3">
<div className="d-block d-md-flex align-items-center">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
@ -157,7 +142,7 @@ const ViewPaymentRequest = ({ requestId }) => {
</div>
</div>
<div className="col-md-12 mb-3">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
@ -170,7 +155,7 @@ const ViewPaymentRequest = ({ requestId }) => {
</div>
</div>
</div>
<div className="col-md-12 mb-3">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
@ -183,7 +168,7 @@ const ViewPaymentRequest = ({ requestId }) => {
</div>
{/* Row 2 */}
<div className="col-md-12 mb-3">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
@ -194,7 +179,7 @@ const ViewPaymentRequest = ({ requestId }) => {
<div className="text-muted">{data?.payee}</div>
</div>
</div>
<div className="col-md-1 mb-3">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
@ -211,8 +196,20 @@ const ViewPaymentRequest = ({ requestId }) => {
</div>
</div>
{/* Row 3 */}
{/* <div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Payment Mode :
</label>
<div className="text-muted">{data?.paymentMode?.name}</div>
</div>
</div> */}
{data?.gstNumber && (
<div className="col-md-12 mb-3">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
@ -226,8 +223,25 @@ const ViewPaymentRequest = ({ requestId }) => {
)}
{/* Row 4 */}
<div className="col-md-12 mb-3">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Status :
</label>
<span
className={`badge bg-label-${
getColorNameFromHex(data?.expenseStatus?.color) ||
"secondary"
}`}
>
{data?.expenseStatus?.name}
</span>
</div>
</div>
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
@ -241,7 +255,18 @@ const ViewPaymentRequest = ({ requestId }) => {
</div>
</div>
<div className="col-md-12 mb-3">
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Project :
</label>
<div className="text-muted">{data?.project?.name}</div>
</div>
</div>
<div className="col-md-6 mb-3">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
@ -257,7 +282,7 @@ const ViewPaymentRequest = ({ requestId }) => {
{/* Row 6 */}
{data?.createdBy && (
<div className="col-md-12 text-start">
<div className="col-md-6 text-start">
<div className="d-flex align-items-center">
<label
className="form-label me-2 mb-0 fw-semibold"
@ -282,7 +307,7 @@ const ViewPaymentRequest = ({ requestId }) => {
</div>
)}
{data?.paidBy && (
<div className="col-md-12 text-start">
<div className="col-md-6 text-start">
<div className="d-flex align-items-center">
<label
className="form-label me-2 mb-0 fw-semibold"
@ -311,8 +336,8 @@ const ViewPaymentRequest = ({ requestId }) => {
<label className="fw-semibold form-label">Description : </label>
<div className="text-muted">{data?.description}</div>
</div>
<div className="col-12 text-start mb-2">
<label className="form-label me-2 mb-1 fw-semibold">
<div className="col-6 text-start">
<label className="form-label me-2 mb-2 fw-semibold">
Attachment :
</label>
@ -512,7 +537,7 @@ const ViewPaymentRequest = ({ requestId }) => {
<i className="bx bx-time-five me-2 "></i>{" "}
<p className="fw-medium">TimeLine</p>
</div>
<PaymentStatusLogs data={data} />
<PaymentStat data={data} />
</div>
</div>
</form>

View File

@ -1,430 +1,490 @@
import React, { useEffect, useState } from 'react'
import Label from '../common/Label';
import { useForm } from 'react-hook-form';
import { useExpenseCategory } from '../../hooks/masterHook/useMaster';
import DatePicker from '../common/DatePicker';
import { zodResolver } from '@hookform/resolvers/zod';
import { defaultRecurringExpense, PaymentRecurringExpense } from './RecurringExpenseSchema';
import { INR_CURRENCY_CODE } from '../../utils/constants';
import { useCurrencies, useProjectName } from '../../hooks/useProjects';
import { useCreateRecurringExpense } from '../../hooks/useExpense';
import React, { useEffect, useState } from "react";
import Label from "../common/Label";
import { Controller, useForm } from "react-hook-form";
import {
useExpenseCategory,
useRecurringStatus,
} from "../../hooks/masterHook/useMaster";
import DatePicker from "../common/DatePicker";
import { zodResolver } from "@hookform/resolvers/zod";
import {
defaultRecurringExpense,
PaymentRecurringExpense,
} from "./RecurringExpenseSchema";
import {
FREQUENCY_FOR_RECURRING,
INR_CURRENCY_CODE,
} from "../../utils/constants";
import { useCurrencies, useProjectName } from "../../hooks/useProjects";
import {
useCreateRecurringExpense,
usePayee,
useRecurringExpenseDetail,
useUpdateRecurringExpense,
} from "../../hooks/useExpense";
import InputSuggestions from "../common/InputSuggestion";
import MultiEmployeeSearchInput from "../common/MultiEmployeeSearchInput";
function ManageRecurringExpense({ closeModal, requestToEdit = null }) {
const {
data,
isLoading,
isError,
error: requestError,
} = useRecurringExpenseDetail(requestToEdit);
const data = {}
const { projectNames, loading: projectLoading, error, isError: isProjectError,
} = useProjectName();
//APIs
const {
projectNames,
loading: projectLoading,
error,
isError: isProjectError,
} = useProjectName();
const {
data: currencyData,
isLoading: currencyLoading,
isError: currencyError,
} = useCurrencies();
const {
data: statusData,
isLoading: statusLoading,
isError: statusError,
} = useRecurringStatus();
const {
data: Payees,
isLoading: isPayeeLoaing,
isError: isPayeeError,
error: payeeError,
} = usePayee();
const {
ExpenseCategories,
loading: ExpenseLoading,
error: ExpenseError,
} = useExpenseCategory();
const { data: currencyData, isLoading: currencyLoading, isError: currencyError } = useCurrencies();
const schema = PaymentRecurringExpense();
const {
register,
control,
watch,
handleSubmit,
setValue,
reset,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: defaultRecurringExpense,
});
const handleClose = () => {
reset();
closeModal();
};
const {
ExpenseCategories,
loading: ExpenseLoading,
error: ExpenseError,
} = useExpenseCategory();
const schema = PaymentRecurringExpense();
const { register, control, watch, handleSubmit, setValue, reset, formState: { errors }, } = useForm({
resolver: zodResolver(schema),
defaultValues: defaultRecurringExpense,
const { mutate: CreateRecurringExpense, isPending: createPending } =
useCreateRecurringExpense(() => {
handleClose();
});
const handleClose = () => {
reset();
closeModal();
// const { mutate: PaymentRequestUpdate, isPending } = useUpdatePaymentRequest(() =>
// handleClose()
// );
useEffect(() => {
if (requestToEdit && data) {
reset({
title: data.title || "",
description: data.description || "",
payee: data.payee || "",
notifyTo: data.notifyTo || "",
currencyId: data.currency.id || "",
amount: data.amount || "",
strikeDate: data.strikeDate?.slice(0, 10) || "",
projectId: data.project.id || "",
paymentBufferDays: data.paymentBufferDays || "",
numberOfIteration: data.numberOfIteration || "",
expenseCategoryId: data.expenseCategory.id || "",
statusId: data.statusId || "",
frequency: data.frequency || "",
isVariable: data.isVariable || false,
});
}
}, [data, reset]);
// console.log("Veer",data)
const onSubmit = (fromdata) => {
let payload = {
...fromdata,
// strikeDate: localToUtc(fromdata.strikeDate),
strikeDate: fromdata.strikeDate
? new Date(fromdata.strikeDate).toISOString()
: null,
};
if (requestToEdit) {
const editPayload = { ...payload, id: data.id };
PaymentRequestUpdate({ id: data.id, payload: editPayload });
} else {
CreateRecurringExpense(payload);
}
console.log("Kartik", payload);
};
const { mutate: CreateRecurringExpense, isPending: createPending } = useCreateRecurringExpense(
() => {
handleClose();
}
);
// const { mutate: PaymentRequestUpdate, isPending } = useUpdatePaymentRequest(() =>
// handleClose()
// );
return (
<div className="container p-3">
<h5 className="m-0">
{requestToEdit
? "Update Expense Recurring "
: "Create Expense Recurring"}
</h5>
<form id="expenseForm" onSubmit={handleSubmit(onSubmit)}>
{/* Project and Category */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label className="form-label" required>
Select Project
</Label>
<select
className="form-select form-select-sm"
{...register("projectId")}
>
<option value="">Select Project</option>
{projectLoading ? (
<option>Loading...</option>
) : (
projectNames?.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))
)}
</select>
{errors.projectId && (
<small className="danger-text">{errors.projectId.message}</small>
)}
</div>
useEffect(() => {
if (requestToEdit && data) {
reset({
title: data.title || "",
description: data.description || "",
payee: data.payee || "",
notifyTo: data.notifyTo || "",
currencyId: data.currency.id || "",
amount: data.amount || "",
strikeDate: data.strikeDate?.slice(0, 10) || "",
projectId: data.project.id || "",
paymentBufferDays: data.paymentBufferDays || "",
numberOfIteration: data.numberOfIteration || "",
expenseCategoryId: data.expenseCategory.id || "",
statusId: data.statusId || "",
frequency: data.frequency || "",
isVariable: data.isVariable || false,
});
}
}, [data, reset]);
// console.log("Veer",data)
const onSubmit = (fromdata) => {
let payload = {
...fromdata,
// strikeDate: localToUtc(fromdata.strikeDate),
strikeDate: fromdata.strikeDate ? new Date(fromdata.strikeDate).toISOString() : null,
};
if (requestToEdit) {
const editPayload = { ...payload, id: data.id};
PaymentRequestUpdate({ id: data.id, payload: editPayload });
} else {
CreateRecurringExpense(payload);
}
console.log("Kartik", payload)
};
return (
<div className="container p-3">
<h5 className="m-0">
{requestToEdit ? "Update Expense Recurring " : "Create Expense Recurring"}
</h5>
<form id="expenseForm" onSubmit={handleSubmit(onSubmit)}>
{/* Project and Category */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label className="form-label" required>
Select Project
</Label>
<select
className="form-select form-select-sm"
{...register("projectId")}
>
<option value="">Select Project</option>
{projectLoading ? (
<option>Loading...</option>
) : (
projectNames?.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))
)}
</select>
{errors.projectId && (
<small className="danger-text">{errors.projectId.message}</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="expenseCategoryId" className="form-label" required>
Expense Category
</Label>
<select
className="form-select form-select-sm"
id="expenseCategoryId"
{...register("expenseCategoryId")}
>
<option value="" disabled>
Select Category
</option>
{ExpenseLoading ? (
<option disabled>Loading...</option>
) : (
ExpenseCategories?.map((expense) => (
<option key={expense.id} value={expense.id}>
{expense.name}
</option>
))
)}
</select>
{errors.expenseCategoryId && (
<small className="danger-text">
{errors.expenseCategoryId.message}
</small>
)}
</div>
</div>
{/* Title and Is Variable */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="title" className="form-label" required>
Title
</Label>
<input
type="text"
id="title"
className="form-control form-control-sm"
{...register("title")}
/>
{errors.title && (
<small className="danger-text">
{errors.title.message}
</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="isVariable" className="form-label" required>
Is Variable
</Label>
<select
id="isVariable"
className="form-select form-select-sm"
{...register("isVariable", {
setValueAs: (v) => v === "true" ? true : v === "false" ? false : false,
})}
>
<option value="false">False</option>
<option value="true">True</option>
</select>
{errors.isVariable && (
<small className="danger-text">{errors.isVariable.message}</small>
)}
</div>
</div>
{/* Date and Amount */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="strikeDate" className="form-label" required>
Strike Date
</Label>
<DatePicker
name="strikeDate"
control={control}
minDate={new Date()}
className='w-100'
/>
{errors.strikeDate && (
<small className="danger-text">
{errors.strikeDate.message}
</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="amount" className="form-label" required>
Amount
</Label>
<input
type="number"
id="amount"
className="form-control form-control-sm"
min="1"
step="0.01"
inputMode="decimal"
{...register("amount", { valueAsNumber: true })}
/>
{errors.amount && (
<small className="danger-text">{errors.amount.message}</small>
)}
</div>
</div>
{/* Payee and Currency */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="payee" className="form-label" required>
Payee (Supplier Name/Transporter Name/Other)
</Label>
<input
type="text"
id="payee"
className="form-control form-control-sm"
{...register("payee")}
/>
{errors.payee && (
<small className="danger-text">
{errors.payee.message}
</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="currencyId" className="form-label" required>
Currency
</Label>
<select
id="currencyId"
className="form-select form-select-sm"
{...register("currencyId")}
>
<option value="">Select Currency</option>
{currencyLoading && <option>Loading...</option>}
{!currencyLoading &&
!currencyError &&
currencyData?.map((currency) => (
<option key={currency.id} value={currency.id}>
{`${currency.currencyName} (${currency.symbol})`}
</option>
))}
</select>
{errors.currencyId && (
<small className="danger-text">{errors.currencyId.message}</small>
)}
</div>
</div>
{/* Notify To and Status Id */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="notifyTo" className="form-label" required>
Notify (E-mail)
</Label>
<input
type="text"
id="notifyTo"
className="form-control form-control-sm"
{...register("notifyTo")}
/>
{errors.notifyTo && (
<small className="danger-text">
{errors.notifyTo.message}
</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="statusId" className="form-label" required>
Status
</Label>
<select
id="statusId"
className="form-select form-select-sm"
{...register("statusId")}
>
<option value="">Select Currency</option>
{currencyLoading && <option>Loading...</option>}
{!currencyLoading &&
!currencyError &&
currencyData?.map((currency) => (
<option key={currency.id} value={currency.id}>
{`${currency.currencyName} (${currency.symbol})`}
</option>
))}
</select>
{errors.statusId && (
<small className="danger-text">{errors.statusId.message}</small>
)}
</div>
</div>
{/* Payment Buffer Days and Number of Iteration */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="paymentBufferDays" className="form-label" required>
Payment Buffer Days
</Label>
<input
type="number"
id="paymentBufferDays"
className="form-control form-control-sm"
min="0"
step="1"
{...register("paymentBufferDays", { valueAsNumber: true })}
/>
{errors.paymentBufferDays && (
<small className="danger-text">{errors.paymentBufferDays.message}</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="numberOfIteration" className="form-label" required>
Number of Iteration
</Label>
<input
type="number"
id="numberOfIteration"
className="form-control form-control-sm"
min="1"
step="1"
{...register("numberOfIteration", { valueAsNumber: true })}
/>
{errors.numberOfIteration && (
<small className="danger-text">{errors.numberOfIteration.message}</small>
)}
</div>
</div>
{/* Description */}
<div className="row my-2 text-start">
<div className="col-md-12">
<Label htmlFor="description" className="form-label" required>
Description
</Label>
<textarea
id="description"
className="form-control form-control-sm"
{...register("description")}
rows="2"
></textarea>
{errors.description && (
<small className="danger-text">
{errors.description.message}
</small>
)}
</div>
</div>
{/* <div className="d-flex justify-content-end gap-3">
<button
type="reset"
// disabled={createPending}
onClick={handleClose}
className="btn btn-label-secondary btn-sm mt-3"
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm mt-3"
// disabled={createPending}
>
{createPending
? "Please Wait..."
: requestToEdit
? "Update"
: "Submit"}
</button>
</div> */}
<div className="d-flex justify-content-end gap-3">
<button
type="reset"
onClick={handleClose}
className="btn btn-label-secondary btn-sm mt-3"
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm mt-3"
>
Submit
</button>
</div>
</form>
<div className="col-md-6">
<Label htmlFor="expenseCategoryId" className="form-label" required>
Expense Category
</Label>
<select
className="form-select form-select-sm"
id="expenseCategoryId"
{...register("expenseCategoryId")}
>
<option value="" disabled>
Select Category
</option>
{ExpenseLoading ? (
<option disabled>Loading...</option>
) : (
ExpenseCategories?.map((expense) => (
<option key={expense.id} value={expense.id}>
{expense.name}
</option>
))
)}
</select>
{errors.expenseCategoryId && (
<small className="danger-text">
{errors.expenseCategoryId.message}
</small>
)}
</div>
</div>
)
{/* Title and Is Variable */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="title" className="form-label" required>
Title
</Label>
<input
type="text"
id="title"
className="form-control form-control-sm"
{...register("title")}
placeholder="Enter title"
/>
{errors.title && (
<small className="danger-text">{errors.title.message}</small>
)}
</div>
<div className="col-md-6 mt-2">
<Label htmlFor="isVariable" className="form-label" required>
Payment Type
</Label>
<Controller
name="isVariable"
control={control}
defaultValue={defaultRecurringExpense.isVariable ?? false}
render={({ field }) => (
<div className="d-flex align-items-center gap-3">
<div className="form-check">
<input
type="radio"
id="isVariableTrue"
className="form-check-input"
checked={field.value === true}
onChange={() => field.onChange(true)}
/>
<Label
htmlFor="isVariableTrue"
className="form-check-label"
>
Is Variable
</Label>
</div>
<div className="form-check">
<input
type="radio"
id="isVariableFalse"
className="form-check-input"
checked={field.value === false}
onChange={() => field.onChange(false)}
/>
<Label
htmlFor="isVariableFalse"
className="form-check-label"
>
Fixed
</Label>
</div>
</div>
)}
/>
{errors.isVariable && (
<small className="danger-text">{errors.isVariable.message}</small>
)}
</div>
</div>
{/* Date and Amount */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="strikeDate" className="form-label" required>
Strike Date
</Label>
<DatePicker
name="strikeDate"
control={control}
minDate={new Date()}
className="w-100"
/>
{errors.strikeDate && (
<small className="danger-text">{errors.strikeDate.message}</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="amount" className="form-label" required>
Amount
</Label>
<input
type="number"
id="amount"
className="form-control form-control-sm"
min="1"
step="0.01"
inputMode="decimal"
{...register("amount", { valueAsNumber: true })}
placeholder="Enter amount"
/>
{errors.amount && (
<small className="danger-text">{errors.amount.message}</small>
)}
</div>
</div>
{/* Payee and Currency */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="payee" className="form-label" required>
Payee (Supplier Name/Transporter Name/Other)
</Label>
<InputSuggestions
organizationList={Payees}
value={watch("payee") || ""}
onChange={(val) =>
setValue("payee", val, { shouldValidate: true })
}
error={errors.payee?.message}
placeholder="Select or enter payee"
/>
</div>
<div className="col-md-6">
<Label htmlFor="currencyId" className="form-label" required>
Currency
</Label>
<select
id="currencyId"
className="form-select form-select-sm"
{...register("currencyId")}
>
<option value="">Select Currency</option>
{currencyLoading && <option>Loading...</option>}
{!currencyLoading &&
!currencyError &&
currencyData?.map((currency) => (
<option key={currency.id} value={currency.id}>
{`${currency.currencyName} (${currency.symbol})`}
</option>
))}
</select>
{errors.currencyId && (
<small className="danger-text">{errors.currencyId.message}</small>
)}
</div>
</div>
{/* Frequency To and Status Id */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="frequency" className="form-label" required>
Frequency
</Label>
<select
id="frequency"
className="form-select form-select-sm"
{...register("frequency", { valueAsNumber: true })}
>
<option value="">Select Frequency</option>
{Object.entries(FREQUENCY_FOR_RECURRING).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
{errors.frequency && (
<small className="danger-text">{errors.frequency.message}</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="statusId" className="form-label" required>
Status
</Label>
<select
id="statusId"
className="form-select form-select-sm"
{...register("statusId")}
>
<option value="">Select Status</option>
{statusLoading && <option>Loading...</option>}
{!currencyLoading &&
!currencyError &&
statusData?.map((status) => (
<option key={status.id} value={status.id}>
{`${status.name} `}
</option>
))}
</select>
{errors.statusId && (
<small className="danger-text">{errors.statusId.message}</small>
)}
</div>
</div>
{/* Payment Buffer Days and Number of Iteration */}
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="paymentBufferDays" className="form-label" required>
Payment Buffer Days
</Label>
<input
type="number"
id="paymentBufferDays"
className="form-control form-control-sm"
min="0"
step="1"
{...register("paymentBufferDays", { valueAsNumber: true })}
placeholder="Enter payment buffer days"
/>
{errors.paymentBufferDays && (
<small className="danger-text">
{errors.paymentBufferDays.message}
</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="numberOfIteration" className="form-label" required>
Number of Iteration
</Label>
<input
type="number"
id="numberOfIteration"
className="form-control form-control-sm"
min="1"
step="1"
{...register("numberOfIteration", { valueAsNumber: true })}
placeholder="Enter number of iterations"
/>
{errors.numberOfIteration && (
<small className="danger-text">
{errors.numberOfIteration.message}
</small>
)}
</div>
</div>
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="notifyTo" className="form-label" required>
Notify Employees
</Label>
<MultiEmployeeSearchInput
control={control}
name="notifyTo"
projectId={watch("projectId")}
placeholder="Select Employees"
forAll={true}
/>
</div>
</div>
{/* Description */}
<div className="row my-2 text-start">
<div className="col-md-12">
<Label htmlFor="description" className="form-label" required>
Description
</Label>
<textarea
id="description"
className="form-control form-control-sm"
{...register("description")}
rows="2"
></textarea>
{errors.description && (
<small className="danger-text">
{errors.description.message}
</small>
)}
</div>
</div>
<div className="d-flex justify-content-end gap-3">
<button
type="reset"
onClick={handleClose}
className="btn btn-label-secondary btn-sm mt-3"
>
Cancel
</button>
<button type="submit" className="btn btn-primary btn-sm mt-3">
Submit
</button>
</div>
</form>
</div>
);
}
export default ManageRecurringExpense
export default ManageRecurringExpense;

View File

@ -0,0 +1,283 @@
import React, { useState } from "react";
import {
EXPENSE_DRAFT,
EXPENSE_REJECTEDBY,
FREQUENCY_FOR_RECURRING,
ITEMS_PER_PAGE,
} from "../../utils/constants";
import {
formatCurrency,
useDebounce,
} from "../../utils/appUtils";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import { ExpenseTableSkeleton } from "../Expenses/ExpenseSkeleton";
import ConfirmModal from "../common/ConfirmModal";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import Error from "../common/Error";
import { useRecurringExpenseContext } from "../../pages/RecurringExpense/RecurringExpensePage";
import { useRecurringExpenseList } from "../../hooks/useExpense";
const RecurringExpenseList = ({ search, filterStatuses }) => {
const { setManageRequest, setVieRequest } = useRecurringExpenseContext();
const navigate = useNavigate();
const [IsDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const SelfId = useSelector(
(store) => store?.globalVariables?.loginUser?.employeeInfo?.id
);
const recurringExpenseColumns = [
{
key: "expenseCategory",
label: "Category",
align: "text-start",
getValue: (e) => e?.expenseCategory?.name || "N/A",
},
{
key: "title",
label: "Title",
align: "text-start",
getValue: (e) => e?.title || "N/A",
},
{
key: "payee",
label: "Payee",
align: "text-start",
getValue: (e) => e?.payee || "N/A",
},
{
key: "frequency",
label: "Frequency",
align: "text-start",
getValue: (e) =>
e?.frequency !== undefined && e?.frequency !== null
? FREQUENCY_FOR_RECURRING[e.frequency] || "N/A"
: "N/A",
},
{
key: "amount",
label: "Amount",
align: "text-end",
getValue: (e) =>
e?.amount
? `${e?.currency?.symbol || ""}${e.amount.toLocaleString()}`
: "N/A",
},
{
key: "createdAt",
label: "Next Generation Date",
align: "text-center",
getValue: (e) =>
e?.createdAt ? formatUTCToLocalTime(e.createdAt) : "N/A",
},
{
key: "status",
label: "Status",
align: "text-start",
getValue: (e) => e?.status?.name || "N/A",
},
];
const [currentPage, setCurrentPage] = useState(1);
const debouncedSearch = useDebounce(search, 500);
const { data, isLoading, isError, error, isRefetching, refetch } =
useRecurringExpenseList(
ITEMS_PER_PAGE,
currentPage,
{},
true,
debouncedSearch
);
const recurringExpenseData = data?.data || [];
const totalPages = data?.totalPages || 1;
if (isError) {
return <Error error={error} isFeteching={isRefetching} refetch={refetch} />;
}
const header = [
"Category",
"Title",
"Amount",
"Payee",
"Frequency",
"Next Generation",
"Status",
"Action",
];
if (isLoading) return <ExpenseTableSkeleton headers={header} />;
const canEditExpense = (recurringExpense) => {
// return (
// (recurringExpense?.expenseStatus?.id === EXPENSE_DRAFT ||
// EXPENSE_REJECTEDBY.includes(recurringExpense?.expenseStatus.id)) &&
// recurringExpense?.createdBy?.id === SelfId
// );
};
const canDeleteExpense = (request) => {
return (
request?.expenseStatus?.id === EXPENSE_DRAFT &&
request?.createdBy?.id === SelfId
);
};
const filteredData = recurringExpenseData.filter((item) =>
filterStatuses.includes(item?.status?.id)
);
const handleDelete = (id) => {
setDeletingId(id);
DeleteExpense(
{ id },
{
onSettled: () => {
setDeletingId(null);
setIsDeleteModalOpen(false);
},
}
);
};
return (
<>
{IsDeleteModalOpen && (
<ConfirmModal
isOpen={IsDeleteModalOpen}
type="delete"
header="Delete Recurring Expense"
message="Are you sure you want to delete?"
onSubmit={handleDelete}
onClose={() => setIsDeleteModalOpen(false)}
paramData={deletingId}
/>
)}
<div className="card page-min-h table-responsive px-sm-4">
<div className="card-datatable" id="payment-request-table">
<table className="table border-top dataTable text-nowrap align-middle">
<thead>
<tr>
{recurringExpenseColumns.map((col) => (
<th key={col.key} className={`sorting ${col.align}`}>
{col.label}
</th>
))}
<th className="text-center">Action</th>
</tr>
</thead>
<tbody>
{filteredData.length > 0 ? (
filteredData.map((recurringExpense) => (
<tr key={recurringExpense.id} className="align-middle" style={{ height: "50px" }}>
{recurringExpenseColumns.map((col) => (
<td
key={col.key}
className={`d-table-cell ${col.align ?? ""} py-3`}
>
{col?.customRender
? col?.customRender(recurringExpense)
: col?.getValue(recurringExpense)}
</td>
))}
<td className="sticky-action-column bg-white">
<div className="d-flex justify-content-center gap-0">
<i
className="bx bx-show text-primary cursor-pointer"
// onClick={() =>
// setVieRequest({
// requestId: recurringExpense.id,
// view: true,
// })
// }
></i>
{/* Uncomment for edit/delete actions */}
<div className="dropdown z-2">
<button
type="button"
className="btn btn-xs btn-icon btn-text-secondary rounded-pill dropdown-toggle hide-arrow p-0 m-0"
data-bs-toggle="dropdown"
>
<i className="bx bx-dots-vertical-rounded text-muted p-0"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end w-auto">
<li
onClick={() =>
setManageRequest({
IsOpen: true,
RecurringId: recurringExpense.id,
})
}
>
<a className="dropdown-item px-2 cursor-pointer py-1">
<i className="bx bx-edit text-primary bx-xs me-2"></i>
Modify
</a>
</li>
<li
onClick={() => {
setIsDeleteModalOpen(true);
setDeletingId(recurringExpense.id);
}}
>
<a className="dropdown-item px-2 cursor-pointer py-1">
<i className="bx bx-trash text-danger bx-xs me-2"></i>
Delete
</a>
</li>
</ul>
</div>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={recurringExpenseColumns.length + 1} className="text-center border-0 py-8">
<p>No Recurring Expense Found</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="d-flex justify-content-end py-3 pe-3">
<nav>
<ul className="pagination mb-0">
{[...Array(totalPages)].map((_, index) => (
<li
key={index}
className={`page-item ${currentPage === index + 1 ? "active" : ""}`}
>
<button
className="page-link"
onClick={() => setCurrentPage(index + 1)}
>
{index + 1}
</button>
</li>
))}
</ul>
</nav>
</div>
)}
</div>
</>
);
};
export default RecurringExpenseList;

View File

@ -1,65 +1,16 @@
import { boolean, z } from "zod";
import { INR_CURRENCY_CODE } from "../../utils/constants";
// export const PaymentRecurringExpense = (expenseTypes) => {
// return z
// .object({
// title: z.string().min(1, { message: "Project is required" }),
// description: z.string().min(1, { message: "Description is required" }),
// payee: z.string().min(1, { message: "Supplier name is required" }),
// notifyTo: z.string().min(1, { message: "Notification is required" }),
// currencyId: z
// .string()
// .min(1, { message: "Currency 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",
// }),
// strikeDate: z.string().min(1, { message: "Date is required" }),
// projectId: z.string().min(1, { message: "Project is required" }),
// paymentBufferDays: z.string().min(1, { message: "Buffer days is required" }),
// numberOfIteration: z.string().min(1, { message: "Iteration is required" }),
// expenseCategoryId: z
// .string()
// .min(1, { message: "Expense Category is required" }),
// statusId: z.string().min(1, { message: "Please select a status" }),
// frequency: z.string().min(1, { message: "Frequency is required" }),
// isVariable: z.boolean().optional(),
// })
// };
export const PaymentRecurringExpense = (expenseTypes) => {
return z.object({
title: z.string().min(1, { message: "Project is required" }),
description: z.string().min(1, { message: "Description is required" }),
payee: z.string().min(1, { message: "Supplier name is required" }),
notifyTo: z.string().min(1, { message: "Notification is required" }),
title: z.string().min(1, { message: "Title is required" }).transform((val) => val.trim()),
description: z.string().min(1, { message: "Description is required" }).transform((val) => val.trim()),
payee: z.string().min(1, { message: "Payee name is required" }).transform((val) => val.trim()),
notifyTo: z.string().min(1, { message: "Notification e-mail is required" }).transform((val) => val.trim()),
currencyId: z
.string()
.min(1, { message: "Currency is required" }),
.min(1, { message: "Currency is required" })
.transform((val) => val.trim()),
amount: z
.number({
@ -76,11 +27,13 @@ export const PaymentRecurringExpense = (expenseTypes) => {
.min(1, { message: "Date is required" })
.refine((val) => !isNaN(Date.parse(val)), {
message: "Invalid date format",
}),
})
.transform((val) => val.trim()),
projectId: z
.string()
.min(1, { message: "Project is required" }),
.min(1, { message: "Project is required" })
.transform((val) => val.trim()),
paymentBufferDays: z
.number({
@ -98,23 +51,28 @@ export const PaymentRecurringExpense = (expenseTypes) => {
expenseCategoryId: z
.string()
.min(1, { message: "Expense Category is required" }),
.min(1, { message: "Expense Category is required" })
.transform((val) => val.trim()),
statusId: z
.string()
.min(1, { message: "Please select a status" }),
.min(1, { message: "Please select a status" })
.transform((val) => val.trim()),
frequency: z
.number({
required_error: "Frequency is required",
invalid_type_error: "Frequency must be a number",
})
.min(1, { message: "Frequency must be greater than 0" }),
.refine((val) => [0, 1, 2, 3, 4, 5].includes(val), {
message: "Invalid frequency selected",
}),
isVariable: z.boolean().optional(),
});
};
export const defaultRecurringExpense = {
title: "",
description: "",
@ -122,37 +80,33 @@ export const defaultRecurringExpense = {
notifyTo: "",
currencyId: "",
amount: 0,
strikeDate: "", // or null if your DatePicker accepts null
strikeDate: "",
projectId: "",
paymentBufferDays: 0,
numberOfIteration: 1,
expenseCategoryId: "",
statusId: "",
frequency: 1,
isVariable: false,
isVariable: true,
};
// export const SearchPaymentRequestSchema = z.object({
// projectIds: z.array(z.string()).optional(),
// statusIds: z.array(z.string()).optional(),
// createdByIds: z.array(z.string()).optional(),
// currencyIds: z.array(z.string()).optional(),
// expenseCategoryIds: z.array(z.string()).optional(),
// payees: z.array(z.string()).optional(),
// startDate: z.string().optional(),
// endDate: z.string().optional(),
// });
// export const defaultPaymentRequestFilter = {
// projectIds: [],
// statusIds: [],
// createdByIds: [],
// currencyIds: [],
// expenseCategoryIds: [],
// payees: [],
// startDate: null,
// endDate: null,
// };
export const SearchRecurringExpenseSchema = z.object({
title: z.array(z.string()).optional(),
description: z.array(z.string()).optional(),
payee: z.array(z.string()).optional(),
notifyTo: z.array(z.string()).optional(),
currencyId: z.array(z.string()).optional(),
amount: z.array(z.string()).optional(),
strikeDate: z.string().optional(),
projectId: z.string().optional(),
paymentBufferDays: z.string().optional(),
numberOfIteration: z.string().optional(),
expenseCategoryId: z.string().optional(),
statusId: z.string().optional(),
frequency: z.string().optional(),
isVariable: z.string().optional(),
});

View File

@ -3,9 +3,6 @@ import { useEmployeesName } from "../../hooks/useEmployees";
import { useDebounce } from "../../utils/appUtils";
import { useController } from "react-hook-form";
import Avatar from "./Avatar";
const EmployeeSearchInput = ({
control,
name,

View File

@ -0,0 +1,182 @@
import { useState, useEffect, useRef } from "react";
import { useEmployeesName } from "../../hooks/useEmployees";
import { useDebounce } from "../../utils/appUtils";
import { useController } from "react-hook-form";
import Avatar from "../common/Avatar";
const MultiEmployeeSearchInput = ({
control,
name,
projectId,
placeholder,
forAll,
}) => {
const {
field: { onChange, value, ref },
fieldState: { error },
} = useController({ name, control });
const [search, setSearch] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const [selectedEmployees, setSelectedEmployees] = useState([]);
const dropdownRef = useRef(null);
const debouncedSearch = useDebounce(search, 500);
const { data: employees, isLoading } = useEmployeesName(
projectId,
debouncedSearch,
forAll
);
useEffect(() => {
if (value && employees?.data) {
// Ensure value is a string (sometimes it may come as array/object)
const stringValue =
typeof value === "string"
? value
: Array.isArray(value)
? value.join(",")
: "";
const emails = stringValue.split(",").filter(Boolean);
const foundEmps = employees.data.filter((emp) =>
emails.includes(emp.email)
);
setSelectedEmployees(foundEmps);
if (forAll && foundEmps.length > 0) {
setSearch(""); // clear search field
}
}
}, [value, employees?.data, forAll]);
const handleSelect = (employee) => {
if (!selectedEmployees.find((emp) => emp.email === employee.email)) {
const newSelected = [...selectedEmployees, employee];
setSelectedEmployees(newSelected);
// Store emails instead of IDs
onChange(
newSelected
.map((e) => e.email)
.filter(Boolean)
.join(",")
);
setSearch("");
setShowDropdown(false);
}
};
const handleRemove = (email) => {
const newSelected = selectedEmployees.filter((e) => e.email !== email);
setSelectedEmployees(newSelected);
onChange(
newSelected
.map((e) => e.email)
.filter(Boolean)
.join(",")
);
};
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setShowDropdown(false);
}
};
const handleEsc = (event) => {
if (event.key === "Escape") setShowDropdown(false);
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEsc);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEsc);
};
}, []);
return (
<div className="position-relative" ref={dropdownRef}>
<div className="d-flex flex-wrap gap-1 mb-1">
{selectedEmployees.map((emp) => (
<div
key={emp.email}
className="badge bg-label-secondary d-flex align-items-center py-0 px-1"
style={{ fontSize: "0.75rem" }}
>
<Avatar
size="xs"
classAvatar="m-0 me-1"
firstName={emp.firstName}
lastName={emp.lastName}
/>
{emp.firstName} {emp.lastName}
<span
className="ms-1"
style={{ cursor: "pointer", fontSize: "0.75rem" }}
onClick={() => handleRemove(emp.email)}
>
×
</span>
</div>
))}
</div>
<input
type="search"
ref={ref}
className="form-control form-control-sm"
placeholder={placeholder}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setShowDropdown(true);
}}
onFocus={() => setShowDropdown(true)}
/>
{showDropdown && (employees?.data?.length > 0 || isLoading) && (
<ul
className="list-group position-absolute bg-white w-100 shadow z-3 rounded-1 px-0"
style={{ maxHeight: 200, overflowY: "auto", zIndex: 1050 }}
>
{isLoading ? (
<li className="list-group-item py-1 px-2 text-muted">
Searching...
</li>
) : (
employees?.data
?.filter(
(emp) => !selectedEmployees.find((e) => e.email === emp.email)
)
.map((emp) => (
<li
key={emp.email}
className="list-group-item list-group-item-action py-1 px-2"
style={{ cursor: "pointer" }}
onClick={() => handleSelect(emp)}
>
<div className="d-flex align-items-center">
<Avatar
size="xs"
classAvatar="m-0 me-2"
firstName={emp.firstName}
lastName={emp.lastName}
/>
<span className="text-muted">{`${emp.firstName} ${emp.lastName}`}</span>
</div>
</li>
))
)}
</ul>
)}
{error && <small className="text-danger">{error.message}</small>}
</div>
);
};
export default MultiEmployeeSearchInput;

View File

@ -1,8 +1,5 @@
import React from "react";
import Avatar from "./Avatar";
import Tooltip from "./Tooltip";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import moment from "moment";
const Timeline = ({ items = [], transparent = true }) => {
return (
@ -11,99 +8,82 @@ const Timeline = ({ items = [], transparent = true }) => {
transparent ? "timeline-transparent text-start" : ""
}`}
>
{items &&
items?.map((item) => (
<li
key={item.id}
className={`timeline-item ${
transparent ? "timeline-item-transparent" : ""
{items.map((item) => (
<li
key={item.id}
className={`timeline-item ${
transparent ? "timeline-item-transparent" : ""
}`}
>
<span
className={`timeline-point timeline-point-${
item.color || "primary"
}`}
>
<span
className={`timeline-point timeline-point-${
item.color || "primary"
}`}
></span>
></span>
<div className="timeline-event">
<div className="timeline-header mb-3 d-flex justify-content-between">
<h6 className="mb-0 text-body">{item.title}</h6>
<small className="text-body-secondary">
<Tooltip text={formatUTCToLocalTime(item.timeAgo, true)}>
{moment.utc(item.timeAgo).local().fromNow()}
</Tooltip>
</small>
<div className="timeline-event">
<div className="timeline-header mb-3 d-flex justify-content-between">
<h6 className="mb-0 text-body">{item.title}</h6>
<small className="text-body-secondary">{item.timeAgo}</small>
</div>
{item.description && <p className="mb-2">{item.description}</p>}
{item.attachments && item.attachments.length > 0 && (
<div className="d-flex align-items-center mb-2">
{item.attachments.map((att, i) => (
<div
key={i}
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"
/>
)}
<span className="h6 mb-0">{att.name}</span>
</div>
))}
</div>
)}
{item.description && <p className="mb-2">{item.description}</p>}
{item.attachments && item.attachments.length > 0 && (
<div className="d-flex align-items-center mb-2">
{item.attachments.map((att, i) => (
<div
key={i}
className="badge bg-lighter rounded d-flex align-items-center gap-2 p-2"
>
{att.icon && (
{item.users && item.users.length > 0 && (
<div className="d-flex flex-wrap align-items-center mb-2">
<ul className="list-unstyled users-list d-flex align-items-center avatar-group m-0">
{item.users.map((user, i) => (
<li key={i} className="avatar me-1" title={user.name}>
{user.avatar ? (
<img
src={att.icon}
alt="file"
width="15"
className="me-2"
src={user.avatar}
alt={user.name}
className="rounded-circle"
width="32"
height="32"
/>
) : (
<Avatar
firstName={user.firstName}
lastName={user.lastName}
/>
)}
<span className="h6 mb-0">{att.name}</span>
</div>
</li>
))}
</div>
)}
</ul>
{item.users && item.users.length > 0 && (
<div className="d-flex flex-wrap align-items-center mb-2">
<ul className="list-unstyled users-list d-flex align-items-center avatar-group m-0">
{item.users.map((user, i) => (
<li key={i} className="m-0" title={user.name}>
{user.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="rounded-circle"
width="32"
height="32"
/>
) : (
<Avatar
size="xs"
firstName={user.firstName}
lastName={user.lastName}
/>
)}
</li>
))}
</ul>
{item.users?.length === 1 && (
<div className="m-0">
<p className="mb-0 text-xs fw-medium">{`${item.users[0].firstName} ${item.users[0].lastName}`}</p>
<small>{item.users[0].role}</small>
</div>
)}
</div>
)}
</div>
</li>
))}
{!items ||
(items.length == 0 && (
<li
className={`timeline-item text-center ${
transparent ? "timeline-item-transparent" : ""
}`}
>
Not action yet.
</li>
))}
{item.users?.length === 1 && (
<div className="m-0">
<p className="mb-0 small fw-medium">{`${item.users[0].firstName} ${item.users[0].lastName}`}</p>
<small>{item.users[0].role}</small>
</div>
)}
</div>
)}
</div>
</li>
))}
</ul>
);
};

View File

@ -10,6 +10,20 @@ import {
} from "@tanstack/react-query";
import showToast from "../../services/toastService";
export const useRecurringStatus = ()=>{
return useQuery({
queryKey:["RecurringStatus"],
queryFn:async()=>{
const resp = await MasterRespository.getRecurringStatus();
return resp.data
}
})
}
export const usePaymentAjustmentHead = (isActive) => {
return useQuery({
queryKey: ["paymentType", isActive],

View File

@ -5,8 +5,7 @@ import { queryClient } from "../layouts/AuthLayout";
import { useSelector } from "react-redux";
import moment from "moment";
// -------------------Query------------------------------------------------------
export const usePayee =()=>{
return useQuery({
queryKey:["payee"],
@ -16,10 +15,6 @@ export const usePayee =()=>{
}
})
}
// -------------------Query------------------------------------------------------
const cleanFilter = (filter) => {
const cleaned = { ...filter };
@ -467,3 +462,52 @@ export const useCreateRecurringExpense = (onSuccessCallBack) => {
});
};
export const useUpdateRecurringExpense = (onSuccessCallBack) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, payload }) => {
const response = await ExpenseRepository.UpdateRecurringExpense(id, payload);
return response.data;
},
onSuccess: (updatedExpense, variables) => {
queryClient.removeQueries({ queryKey: ["recurringExpense", variables.id] });
queryClient.invalidateQueries({ queryKey: ["recurringExpenseList"] });
showToast("Recurring Expense updated Successfully", "success");
if (onSuccessCallBack) onSuccessCallBack();
},
onError: (error) => {
showToast("Something went wrong.Please try again later.", "error");
},
});
};
export const useRecurringExpenseList = (
pageSize,
pageNumber,
filter,
isActive,
searchString = "",
) => {
return useQuery({
queryKey: ["recurringExpenseList",pageSize,pageNumber,filter,isActive,searchString],
queryFn: async()=>{
debugger
const resp = await ExpenseRepository.GetRecurringExpenseList(pageSize,pageNumber,filter,isActive,searchString);
return resp.data;
},
keepPreviousData: true,
});
};
export const useRecurringExpenseDetail =(RequestId)=>{
return useQuery({
queryKey:['recurringExpense',RequestId],
queryFn:async()=>{
RequestId
const resp = await ExpenseRepository.GetRecurringExpense(RequestId);
return resp.data;
},
enabled:!!RequestId
})
}

View File

@ -1,10 +1,4 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
} from "react";
import React, { createContext, useContext, useState, useEffect, useRef } from "react";
import { useForm, useFormContext } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSelector } from "react-redux";
@ -25,10 +19,7 @@ import {
VIEW_SELF_EXPENSE,
} from "../../utils/constants";
import {
defaultFilter,
SearchSchema,
} from "../../components/Expenses/ExpenseSchema";
import { defaultFilter, SearchSchema } from "../../components/Expenses/ExpenseSchema";
import PreviewDocument from "../../components/Expenses/PreviewDocument";
// Context
@ -111,7 +102,7 @@ const ExpensePage = () => {
setManageExpenseModal,
setDocumentView,
filterData,
removeFilterChip,
removeFilterChip
};
return (
@ -137,6 +128,7 @@ const ExpensePage = () => {
</div>
<div className="col-6 text-end mt-2 mt-sm-0">
{IsCreatedAble && (
<button
className="btn btn-sm btn-primary"
@ -159,6 +151,8 @@ const ExpensePage = () => {
</div>
</div>
<ExpenseList
filters={filters}
groupBy={groupBy}
@ -196,7 +190,7 @@ const ExpensePage = () => {
{viewExpense.view && (
<GlobalModel
isOpen
size="xl"
size="lg"
modalType="top"
closeModal={() => setViewExpense({ expenseId: null, view: false })}
>

View File

@ -15,7 +15,7 @@ export const PaymentRequestContext = createContext();
export const usePaymentRequestContext = () => {
const context = useContext(PaymentRequestContext);
if (!context) {
throw new Error("usePaymentRequestContext must be used within an RequestPaymentProvider");
throw new Error("usePaymentRequestContext must be used within an ExpenseProvider");
}
return context;
};

View File

@ -4,7 +4,9 @@ import GlobalModel from "../../components/common/GlobalModel";
import { useFab } from "../../Context/FabContext";
// import { defaultPaymentRequestFilter,SearchPaymentRequestSchema } from "../../components/PaymentRequest/PaymentRequestSchema";
import ManageRecurringExpense from "../../components/RecurringExpense/ManageRecurringExpense";
import RecurringExpenseList from "../../components/RecurringExpense/RecurringRexpenseList";
import RecurringExpenseList from "../../components/RecurringExpense/RecurringExpenseList";
import { PAYEE_RECURRING_EXPENSE } from "../../utils/constants";
import { SearchRecurringExpenseSchema } from "../../components/RecurringExpense/RecurringExpenseSchema";
export const RecurringExpenseContext = createContext();
export const useRecurringExpenseContext = () => {
@ -20,8 +22,10 @@ const RecurringExpensePage = () => {
RequestId: null,
});
const [ViewRequest, setVieRequest] = useState({ view: false, requestId: null })
const { setOffcanvasContent, setShowTrigger } = useFab();
// const [filters, setFilters] = useState(defaultPaymentRequestFilter);
const [selectedStatuses, setSelectedStatuses] = useState(
PAYEE_RECURRING_EXPENSE.map((s) => s.id)
);
const [search, setSearch] = useState("");
@ -30,19 +34,13 @@ const RecurringExpensePage = () => {
setVieRequest
};
useEffect(() => {
setShowTrigger(true);
setOffcanvasContent(
"Payment Request Filters",
// <PaymentRequestFilterPanel onApply={setFilters} />
const handleStatusChange = (id) => {
setSelectedStatuses((prev) =>
prev.includes(id)
? prev.filter((s) => s !== id)
: [...prev, id]
);
return () => {
setShowTrigger(false);
setOffcanvasContent("", null);
};
}, []);
};
return (
<RecurringExpenseContext.Provider value={contextValue}>
@ -57,19 +55,47 @@ const RecurringExpensePage = () => {
{/* Top Bar */}
<div className="card my-3 px-sm-4 px-0">
<div className="card-body py-2 px-3">
<div className="row align-items-center">
<div className="col-6">
<div className="card-body py-2 px-1">
<div className="d-flex flex-wrap align-items-center justify-content-between">
{/* Left side: Search + Filter */}
<div className="d-flex align-items-center flex-wrap">
<input
type="search"
className="form-control form-control-sm w-auto"
placeholder="Search Recurring Expense.."
placeholder="Search Recurring Expense..."
value={search}
// onChange={(e) => setSearch(e.target.value)}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="dropdown">
<a
className="dropdown-toggle hide-arrow cursor-pointer p-1"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Filter"
>
<i className="bx bx-slider-alt ms-1"></i>
</a>
<ul className="dropdown-menu p-2 text-capitalize">
{PAYEE_RECURRING_EXPENSE.map(({ id, label }) => (
<li key={id}>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
checked={selectedStatuses.includes(id)}
onChange={() => handleStatusChange(id)}
/>
<label className="form-check-label">{label}</label>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="col-6 text-end mt-2 mt-sm-0">
{/* Right side: Add Button */}
<div className="mt-2 mt-sm-0">
<button
className="btn btn-sm btn-primary"
type="button"
@ -82,18 +108,15 @@ const RecurringExpensePage = () => {
>
<i className="bx bx-plus-circle me-2"></i>
<span className="d-none d-md-inline-block">
Add Payment Request
Add Recurring Expense
</span>
</button>
</div>
</div>
</div>
</div>
{/* <PaymentRequestList
search={search}
filters={filters}
/> */}
<RecurringExpenseList/>
<RecurringExpenseList filterStatuses={selectedStatuses} search={search} />
{/* Add/Edit Modal */}
{ManageRequest.IsOpen && (

View File

@ -1,7 +1,6 @@
import { api } from "../utils/axiosClient";
const ExpenseRepository = {
GetPayee: () => api.get("/api/Expense/payment-request/payee"),
//#region Expense
GetExpenseList: (pageSize, pageNumber, filter, searchString) => {
const payloadJsonString = JSON.stringify(filter);
@ -40,20 +39,33 @@ const ExpenseRepository = {
GetPaymentRequestFilter: () => api.get("/api/Expense/payment-request/filter"),
ActionOnPaymentRequest: (data) =>
api.post("/api/Expense/payment-request/action", data),
DeletePaymentRequest:()=>api.get("delete here come"),
CreatePaymentRequestExpense:(data)=>api.post('/api/Expense/payment-request/expense/create',data),
DeletePaymentRequest: () => api.get("delete here come"),
CreatePaymentRequestExpense: (data) =>
api.post("/api/Expense/payment-request/expense/create", data),
GetPayee:()=>api.get('/api/Expense/payment-request/payee'),
//#endregion
//#region Recurring Expense
CreateRecurringExpense: (data) => api.post("/api/Expense/recurring-payment/create", data),
//#endregion
//#region Advance Payment
GetTranctionList: (employeeId)=>api.get(`/api/Expense/get/transactions/${employeeId}`),
GetRecurringExpenseList:(pageSize, pageNumber, filter, searchString) => {
const payloadJsonString = JSON.stringify(filter);
return api.get(
`/api/expense/get/recurring-payment/list?pageSize=${pageSize}&pageNumber=${pageNumber}&filter=${payloadJsonString}&searchString=${searchString}`
);
},
CreateRecurringExpense: (data) =>
api.post("/api/Expense/recurring-payment/create", data),
UpdateRecurringExpense: (id, data) =>
api.put(`/api/Expense/recurring-payment/edit/${id}`, data),
GetRecurringExpense: (id) =>
api.get(`/api/Expense/get/recurring-payment/details/${id}`),
//#endregion
//#region Advance Payment
GetTranctionList: (employeeId) =>
api.get(`/api/Expense/get/transactions/${employeeId}`),
//#endregion
};
export default ExpenseRepository;

View File

@ -141,4 +141,7 @@ export const MasterRespository = {
api.post(`/api/Master/payment-adjustment-head`, data),
updatePaymentAjustmentHead: (id, data) =>
api.put(`/api/Master/payment-adjustment-head/edit/${id}`, data),
getRecurringStatus:()=>api.get(`/api/Master/recurring-status/list`)
};

View File

@ -108,7 +108,7 @@ export function localToUtc(dateString) {
export const formatCurrency = (amount, currency = "INR", locale = "en-US") => {
return new Intl.NumberFormat(locale, {
style: "currency",
notation: "compact", // standard or compact
notation: "compact",
compactDisplay: "short",
currency: currency,
minimumFractionDigits: 0,

View File

@ -160,10 +160,10 @@ export const PROJECT_STATUS = [
export const DEFAULT_CURRENCY = "78e96e4a-7ce0-4164-ae3a-c833ad45ec2c";
export const EXPENSE_STATUS = {
daft:"297e0d8f-f668-41b5-bfea-e03b354251c8",
review_pending:"6537018f-f4e9-4cb3-a210-6c3b2da999d7",
payment_pending:"f18c5cfd-7815-4341-8da2-2c2d65778e27",
approve_pending:"4068007f-c92f-4f37-a907-bc15fe57d4d8",
daft: "297e0d8f-f668-41b5-bfea-e03b354251c8",
review_pending: "6537018f-f4e9-4cb3-a210-6c3b2da999d7",
payment_pending: "f18c5cfd-7815-4341-8da2-2c2d65778e27",
approve_pending: "4068007f-c92f-4f37-a907-bc15fe57d4d8",
}
@ -178,4 +178,32 @@ export const ALLOW_PROJECTSTATUS_ID = [
export const DEFAULT_EMPTY_STATUS_ID = "00000000-0000-0000-0000-000000000000";
export const FREQUENCY_FOR_RECURRING = {
0: "Monthly",
1: "Quarterly",
2: "Half-Yearly",
3: "Yearly",
4: "Daily",
5: "Weekly"
};
export const PAYEE_RECURRING_EXPENSE = [
{
id: "da462422-13b2-45cc-a175-910a225f6fc8",
label: "Active",
},
{
id: "306856fb-5655-42eb-bf8b-808bb5e84725",
label: "Completed",
},
{
id: "3ec864d2-8bf5-42fb-ba70-5090301dd816",
label: "De-Activited",
},
{
id: "8bfc9346-e092-4a80-acbf-515ae1ef6868",
label: "Paused",
},
];