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

This commit is contained in:
Kartik Sharma 2025-11-07 14:44:20 +05:30
commit 626e9bb596
13 changed files with 787 additions and 512 deletions

View File

@ -46,6 +46,8 @@
<link rel="stylesheet" href="/assets/vendor/libs/bs-stepper/bs-stepper.css" /> <link rel="stylesheet" href="/assets/vendor/libs/bs-stepper/bs-stepper.css" />
<link rel="stylesheet" href="/assets/vendor/libs/bootstrap-select/bootstrap-select.css" /> <link rel="stylesheet" href="/assets/vendor/libs/bootstrap-select/bootstrap-select.css" />
<link rel="stylesheet" href="/assets/vendor/libs/select2/select2.css" /> <link rel="stylesheet" href="/assets/vendor/libs/select2/select2.css" />
<link rel="stylesheet" href="/assets/vendor/libs/tagify/tagify.css" />
<link rel="stylesheet" href="/assets/vendor/libs/tagify/tagify.js" />
<link rel="stylesheet" href="/assets/vendor/libs/animate-css/animate.css" /> <link rel="stylesheet" href="/assets/vendor/libs/animate-css/animate.css" />
<link rel="stylesheet" href="/assets/vendor/libs/sweetalert2/sweetalert2.css" /> <link rel="stylesheet" href="/assets/vendor/libs/sweetalert2/sweetalert2.css" />

View File

@ -725,7 +725,8 @@
transition: none; transition: none;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
padding: calc(2px - var(--bs-border-width)) 0.4375rem 0.4231rem !important; /* padding: calc(2px - var(--bs-border-width)) 0.4375rem 0.4231rem !important; */
padding: calc(2px - var(--bs-border-width)) 0.4375rem 0.2rem !important;
} }
.fv-plugins-bootstrap5-row-invalid .tagify.form-control { .fv-plugins-bootstrap5-row-invalid .tagify.form-control {
padding: 0 calc(0.4375rem - var(--bs-border-width)) calc(0.4375rem - 2px) !important; padding: 0 calc(0.4375rem - var(--bs-border-width)) calc(0.4375rem - 2px) !important;

View File

@ -407,7 +407,7 @@ const ViewExpense = ({ ExpenseId }) => {
</div> </div>
<div className="col-12 col-md-6 text-start mb-1"> <div className="col-12 col-md-6 text-start mb-1">
<Label className="form-label" required>Transaction Date </Label> <Label className="form-label" required>Transaction Date </Label>
<DatePicker <DatePicker className="w-100"
name="reimburseDate" name="reimburseDate"
control={control} control={control}
minDate={data?.transactionDate} minDate={data?.transactionDate}

View File

@ -32,7 +32,7 @@ const MakeExpense = ({ onClose }) => {
const files = watch("billAttachments"); const files = watch("billAttachments");
const onFileChange = async (e) => { const onFileChange = async (e) => {
const newFiles = Array.from(e.target.files); const newFiles = Array.from(e.target.files);
if (newFiles.length === 0) return; if (newFiles?.length === 0) return;
const existingFiles = watch("billAttachments") || []; const existingFiles = watch("billAttachments") || [];
@ -199,7 +199,7 @@ const MakeExpense = ({ onClose }) => {
{errors.billAttachments.message} {errors.billAttachments.message}
</small> </small>
)} )}
{files.length > 0 && ( {files?.length > 0 && (
<Filelist <Filelist
files={files} files={files}
removeFile={removeFile} removeFile={removeFile}

View File

@ -75,12 +75,12 @@ function ManagePaymentRequest({ closeModal, requestToEdit = null }) {
const files = watch("billAttachments"); const files = watch("billAttachments");
const onFileChange = async (e) => { const onFileChange = async (e) => {
const newFiles = Array.from(e.target.files); const newFiles = Array.from(e.target.files);
if (newFiles.length === 0) return; if (newFiles?.length === 0) return;
const existingFiles = watch("billAttachments") || []; const existingFiles = watch("billAttachments") || [];
const parsedFiles = await Promise.all( const parsedFiles = await Promise.all(
newFiles.map(async (file) => { newFiles?.map(async (file) => {
const base64Data = await toBase64(file); const base64Data = await toBase64(file);
return { return {
fileName: file.name, fileName: file.name,
@ -175,7 +175,7 @@ function ManagePaymentRequest({ closeModal, requestToEdit = null }) {
}, [data, reset]); }, [data, reset]);
useEffect(() => { useEffect(() => {
if (!requestToEdit && currencyData && currencyData.length > 0) { if (!requestToEdit && currencyData && currencyData?.length > 0) {
const inrCurrency = currencyData.find((c) => c.id === INR_CURRENCY_CODE); const inrCurrency = currencyData.find((c) => c.id === INR_CURRENCY_CODE);
if (inrCurrency) { if (inrCurrency) {
setValue("currencyId", INR_CURRENCY_CODE, { shouldValidate: true }); setValue("currencyId", INR_CURRENCY_CODE, { shouldValidate: true });
@ -480,7 +480,7 @@ function ManagePaymentRequest({ closeModal, requestToEdit = null }) {
{errors.billAttachments.message} {errors.billAttachments.message}
</small> </small>
)} )}
{files.length > 0 && ( {files?.length > 0 && (
<Filelist <Filelist
files={files} files={files}
removeFile={removeFile} removeFile={removeFile}

View File

@ -79,7 +79,7 @@ const ViewPaymentRequest = ({ requestId }) => {
? status.permissionIds ? status.permissionIds
: []; : [];
if (permissionIds.length === 0) return true; if (permissionIds?.length === 0) return true;
if (permissionIds.includes(PROCESS_EXPENSE)) { if (permissionIds.includes(PROCESS_EXPENSE)) {
setIsPaymentProcess(true); setIsPaymentProcess(true);
} }
@ -393,7 +393,7 @@ const ViewPaymentRequest = ({ requestId }) => {
</div> </div>
)} )}
{Array.isArray(data?.nextStatus) && data?.nextStatus.length > 0 ? ( {Array.isArray(data?.nextStatus) && data?.nextStatus?.length > 0 ? (
<> <>
{IsPaymentProcess && nextStatusWithPermission?.length > 0 && ( {IsPaymentProcess && nextStatusWithPermission?.length > 0 && (
<div className="row"> <div className="row">
@ -478,7 +478,7 @@ const ViewPaymentRequest = ({ requestId }) => {
</div> </div>
)} )}
<div className="col-12 mb-3 text-start"> <div className="col-12 mb-3 text-start">
{((nextStatusWithPermission.length > 0 && {((nextStatusWithPermission?.length > 0 &&
!isRejectedRequest) || !isRejectedRequest) ||
(isRejectedRequest && isCreatedBy)) && ( (isRejectedRequest && isCreatedBy)) && (
<> <>

View File

@ -1,179 +1,232 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from "react";
import Label from '../common/Label'; import Label from "../common/Label";
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from "react-hook-form";
import { useExpenseCategory, useRecurringStatus } from '../../hooks/masterHook/useMaster'; import {
import DatePicker from '../common/DatePicker'; useExpenseCategory,
import { zodResolver } from '@hookform/resolvers/zod'; useRecurringStatus,
import { defaultRecurringExpense, PaymentRecurringExpense } from './RecurringExpenseSchema'; } from "../../hooks/masterHook/useMaster";
import { FREQUENCY_FOR_RECURRING, INR_CURRENCY_CODE } from '../../utils/constants'; import DatePicker from "../common/DatePicker";
import { useCurrencies, useProjectName } from '../../hooks/useProjects'; import { zodResolver } from "@hookform/resolvers/zod";
import { useCreateRecurringExpense, usePayee, useRecurringExpenseDetail, useUpdateRecurringExpense } from '../../hooks/useExpense'; import {
import InputSuggestions from '../common/InputSuggestion'; defaultRecurringExpense,
import MultiEmployeeSearchInput from '../common/MultiEmployeeSearchInput'; 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";
import UsersTagInput from "../common/usesInput";
import { useEmployeesName } from "../../hooks/useEmployees";
function ManageRecurringExpense({ closeModal, requestToEdit = null }) { function ManageRecurringExpense({ closeModal, requestToEdit = null }) {
const { const {
data, data,
isLoading, isLoading,
isError, isError,
error: requestError, error: requestError,
} = useRecurringExpenseDetail(requestToEdit); } = useRecurringExpenseDetail(requestToEdit);
//APIs const { data: employees } = useEmployeesName()
const { projectNames, loading: projectLoading, error, isError: isProjectError, } = useProjectName(); //APIs
const { data: currencyData, isLoading: currencyLoading, isError: currencyError } = useCurrencies(); const {
const { data: statusData, isLoading: statusLoading, isError: statusError } = useRecurringStatus(); projectNames,
const { data: Payees, isLoading: isPayeeLoaing, isError: isPayeeError, error: payeeError } = usePayee() loading: projectLoading,
const { ExpenseCategories, loading: ExpenseLoading, error: ExpenseError } = useExpenseCategory(); 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 schema = PaymentRecurringExpense(); const schema = PaymentRecurringExpense();
const { register, control, watch, handleSubmit, setValue, reset, formState: { errors }, } = useForm({ const {
resolver: zodResolver(schema), register,
defaultValues: defaultRecurringExpense, control,
watch,
handleSubmit,
setValue,
reset,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: defaultRecurringExpense,
});
const handleClose = () => {
reset();
closeModal();
};
const { mutate: CreateRecurringExpense, isPending: createPending } =
useCreateRecurringExpense(() => {
handleClose();
}); });
const handleClose = () => { const { mutate: RecurringExpenseUpdate, isPending } =
reset(); useUpdateRecurringExpense(() => handleClose());
closeModal(); const handleEmailGetting = (userArray = []) => {
if (!Array.isArray(userArray) || userArray.length === 0) return [];
return userArray
.map((empId) => {
const foundUser = employees?.data?.find((user) => user.id === empId);
return foundUser?.email || null;
})
.filter(Boolean).join(",")
};
useEffect(() => {
if (requestToEdit && data) {
reset({
title: data.title || "",
description: data.description || "",
payee: data.payee || "",
notifyTo: data.notifyTo ? data.notifyTo.map((usr)=>usr.id) : [],
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.status.id || "",
frequency: data.frequency || "",
isVariable: data.isVariable || false,
});
}
}, [data, reset]);
useEffect(() => {
if (!requestToEdit && currencyData && currencyData.length > 0) {
const inrCurrency = currencyData.find((c) => c.id === INR_CURRENCY_CODE);
if (inrCurrency) {
setValue("currencyId", INR_CURRENCY_CODE, { shouldValidate: true });
}
}
}, [currencyData, requestToEdit, setValue]);
const onSubmit = (fromdata) => {
let payload = {
...fromdata,
strikeDate: fromdata.strikeDate
? new Date(fromdata.strikeDate).toISOString()
: null,
notifyTo:handleEmailGetting(fromdata.notifyTo)
}; };
if (requestToEdit) {
const editPayload = { ...payload, id: data.id };
RecurringExpenseUpdate({ id: data.id, payload: editPayload });
} else {
CreateRecurringExpense(payload);
}
};
const { mutate: CreateRecurringExpense, isPending: createPending } = useCreateRecurringExpense( return (
() => { <div className="container p-3">
handleClose(); <h5 className="m-0">
} {requestToEdit
); ? "Update Expense Recurring "
const { mutate: RecurringExpenseUpdate, isPending } = useUpdateRecurringExpense(() => : "Create Expense Recurring"}
handleClose() </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(() => { <div className="col-md-6">
if (requestToEdit && data) { <Label htmlFor="expenseCategoryId" className="form-label" required>
reset({ Expense Category
title: data.title || "", </Label>
description: data.description || "", <select
payee: data.payee || "", className="form-select form-select-sm"
notifyTo: data.notifyTo || "", id="expenseCategoryId"
currencyId: data.currency.id || "", {...register("expenseCategoryId")}
amount: data.amount || "", >
strikeDate: data.strikeDate?.slice(0, 10) || "", <option value="" disabled>
projectId: data.project.id || "", Select Category
paymentBufferDays: data.paymentBufferDays || "", </option>
numberOfIteration: data.numberOfIteration || "", {ExpenseLoading ? (
expenseCategoryId: data.expenseCategory.id || "", <option disabled>Loading...</option>
statusId: data.statusId || "", ) : (
frequency: data.frequency || "", ExpenseCategories?.map((expense) => (
isVariable: data.isVariable || false, <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">
}, [data, reset]); <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">
useEffect(() => {
if (!requestToEdit && currencyData && currencyData.length > 0) {
const inrCurrency = currencyData.find(
(c) => c.id === INR_CURRENCY_CODE
);
if (inrCurrency) {
setValue("currencyId", INR_CURRENCY_CODE, { shouldValidate: true });
}
}
}, [currencyData, requestToEdit, setValue]);
const onSubmit = (fromdata) => {
let payload = {
...fromdata,
strikeDate: fromdata.strikeDate ? new Date(fromdata.strikeDate).toISOString() : null,
};
if (requestToEdit) {
const editPayload = { ...payload, id: data.id };
RecurringExpenseUpdate({ id: data.id, payload: editPayload });
} else {
CreateRecurringExpense(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")}
placeholder="Enter title"
/>
{errors.title && (
<small className="danger-text">
{errors.title.message}
</small>
)}
</div>
{/* <div className="col-md-6">
<Label htmlFor="isVariable" className="form-label" required> <Label htmlFor="isVariable" className="form-label" required>
Is Variable Is Variable
</Label> </Label>
@ -192,282 +245,289 @@ function ManageRecurringExpense({ closeModal, requestToEdit = null }) {
)} )}
</div> */} </div> */}
<div className="col-md-6 mt-2"> <div className="col-md-6 mt-2">
<Label htmlFor="isVariable" className="form-label" required> <Label htmlFor="isVariable" className="form-label" required>
Payment Type Payment Type
</Label> </Label>
<Controller <Controller
name="isVariable" name="isVariable"
control={control} control={control}
defaultValue={defaultRecurringExpense.isVariable ?? false} defaultValue={defaultRecurringExpense.isVariable ?? false}
render={({ field }) => ( render={({ field }) => (
<div className="d-flex align-items-center gap-3"> <div className="d-flex align-items-center gap-3">
<div className="form-check"> <div className="form-check">
<input <input
type="radio" type="radio"
id="isVariableTrue" id="isVariableTrue"
className="form-check-input" className="form-check-input"
checked={field.value === true} checked={field.value === true}
onChange={() => field.onChange(true)} onChange={() => field.onChange(true)}
/> />
<Label htmlFor="isVariableTrue" className="form-check-label"> <Label
Is Variable htmlFor="isVariableTrue"
</Label> className="form-check-label"
</div> >
Is Variable
</Label>
</div>
<div className="form-check"> <div className="form-check">
<input <input
type="radio" type="radio"
id="isVariableFalse" id="isVariableFalse"
className="form-check-input" className="form-check-input"
checked={field.value === false} checked={field.value === false}
onChange={() => field.onChange(false)} onChange={() => field.onChange(false)}
/> />
<Label htmlFor="isVariableFalse" className="form-check-label"> <Label
Fixed htmlFor="isVariableFalse"
</Label> className="form-check-label"
</div> >
</div> Fixed
)} </Label>
/> </div>
{errors.isVariable && (
<small className="danger-text">{errors.isVariable.message}</small>
)}
</div>
</div> </div>
)}
/>
{errors.isVariable && (
<small className="danger-text">{errors.isVariable.message}</small>
)}
</div>
</div>
{/* Date and Amount */} {/* Date and Amount */}
<div className="row my-2 text-start"> <div className="row my-2 text-start">
<div className="col-md-6"> <div className="col-md-6">
<Label htmlFor="strikeDate" className="form-label" required> <Label htmlFor="strikeDate" className="form-label" required>
Strike Date Strike Date
</Label> </Label>
<DatePicker <DatePicker
name="strikeDate" name="strikeDate"
control={control} control={control}
minDate={new Date()} minDate={new Date()}
className='w-100' className="w-100"
/> />
{errors.strikeDate && ( {errors.strikeDate && (
<small className="danger-text"> <small className="danger-text">{errors.strikeDate.message}</small>
{errors.strikeDate.message} )}
</small> </div>
)}
</div>
<div className="col-md-6"> <div className="col-md-6">
<Label htmlFor="amount" className="form-label" required> <Label htmlFor="amount" className="form-label" required>
Amount Amount
</Label> </Label>
<input <input
type="number" type="number"
id="amount" id="amount"
className="form-control form-control-sm" className="form-control form-control-sm"
min="1" min="1"
step="0.01" step="0.01"
inputMode="decimal" inputMode="decimal"
{...register("amount", { valueAsNumber: true })} {...register("amount", { valueAsNumber: true })}
placeholder="Enter amount" placeholder="Enter amount"
/> />
{errors.amount && ( {errors.amount && (
<small className="danger-text">{errors.amount.message}</small> <small className="danger-text">{errors.amount.message}</small>
)} )}
</div> </div>
</div> </div>
{/* Payee and Currency */} {/* Payee and Currency */}
<div className="row my-2 text-start"> <div className="row my-2 text-start">
<div className="col-md-6"> <div className="col-md-6">
<Label htmlFor="payee" className="form-label" required> <Label htmlFor="payee" className="form-label" required>
Payee (Supplier Name/Transporter Name/Other) Payee (Supplier Name/Transporter Name/Other)
</Label> </Label>
<InputSuggestions <InputSuggestions
organizationList={Payees} organizationList={Payees}
value={watch("payee") || ""} value={watch("payee") || ""}
onChange={(val) => onChange={(val) =>
setValue("payee", val, { shouldValidate: true }) setValue("payee", val, { shouldValidate: true })
} }
error={errors.payee?.message} error={errors.payee?.message}
placeholder="Select or enter payee" placeholder="Select or enter payee"
/> />
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<Label htmlFor="currencyId" className="form-label" required> <Label htmlFor="currencyId" className="form-label" required>
Currency Currency
</Label> </Label>
<select <select
id="currencyId" id="currencyId"
className="form-select form-select-sm" className="form-select form-select-sm"
{...register("currencyId")} {...register("currencyId")}
> >
<option value="">Select Currency</option> <option value="">Select Currency</option>
{currencyLoading && <option>Loading...</option>} {currencyLoading && <option>Loading...</option>}
{!currencyLoading && {!currencyLoading &&
!currencyError && !currencyError &&
currencyData?.map((currency) => ( currencyData?.map((currency) => (
<option key={currency.id} value={currency.id}> <option key={currency.id} value={currency.id}>
{`${currency.currencyName} (${currency.symbol})`} {`${currency.currencyName} (${currency.symbol})`}
</option> </option>
))} ))}
</select> </select>
{errors.currencyId && ( {errors.currencyId && (
<small className="danger-text">{errors.currencyId.message}</small> <small className="danger-text">{errors.currencyId.message}</small>
)} )}
</div> </div>
</div> </div>
{/* Frequency To and Status Id */} {/* Frequency To and Status Id */}
<div className="row my-2 text-start"> <div className="row my-2 text-start">
<div className="col-md-6"> <div className="col-md-6">
<Label htmlFor="frequency" className="form-label" required> <Label htmlFor="frequency" className="form-label" required>
Frequency Frequency
</Label> </Label>
<select <select
id="frequency" id="frequency"
className="form-select form-select-sm" className="form-select form-select-sm"
{...register("frequency", { valueAsNumber: true })} {...register("frequency", { valueAsNumber: true })}
> >
<option value="">Select Frequency</option> <option value="">Select Frequency</option>
{Object.entries(FREQUENCY_FOR_RECURRING).map(([key, label]) => ( {Object.entries(FREQUENCY_FOR_RECURRING).map(([key, label]) => (
<option key={key} value={key}> <option key={key} value={key}>
{label} {label}
</option> </option>
))} ))}
</select> </select>
{errors.frequency && ( {errors.frequency && (
<small className="danger-text">{errors.frequency.message}</small> <small className="danger-text">{errors.frequency.message}</small>
)} )}
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<Label htmlFor="statusId" className="form-label" required> <Label htmlFor="statusId" className="form-label" required>
Status Status
</Label> </Label>
<select <select
id="statusId" id="statusId"
className="form-select form-select-sm" className="form-select form-select-sm"
{...register("statusId")} {...register("statusId")}
> >
<option value="">Select Status</option> <option value="">Select Status</option>
{statusLoading && <option>Loading...</option>} {statusLoading && <option>Loading...</option>}
{!statusLoading && !statusError && statusData?.map((status) => ( {!statusLoading &&
<option key={status.id} value={status.id}> !statusError &&
{status.name} statusData?.map((status) => (
</option> <option key={status.id} value={status.id}>
))} {status.name}
</select> </option>
{errors.statusId && ( ))}
<small className="danger-text">{errors.statusId.message}</small> </select>
)} {errors.statusId && (
</div> <small className="danger-text">{errors.statusId.message}</small>
</div> )}
</div>
</div>
{/* Payment Buffer Days and Number of Iteration */} {/* Payment Buffer Days and Number of Iteration */}
<div className="row my-2 text-start"> <div className="row my-2 text-start">
<div className="col-md-6"> <div className="col-md-6">
<Label htmlFor="paymentBufferDays" className="form-label" required> <Label htmlFor="paymentBufferDays" className="form-label" required>
Payment Buffer Days Payment Buffer Days
</Label> </Label>
<input <input
type="number" type="number"
id="paymentBufferDays" id="paymentBufferDays"
className="form-control form-control-sm" className="form-control form-control-sm"
min="0" min="0"
step="1" step="1"
{...register("paymentBufferDays", { valueAsNumber: true })} {...register("paymentBufferDays", { valueAsNumber: true })}
placeholder="Enter payment buffer days" placeholder="Enter payment buffer days"
/> />
{errors.paymentBufferDays && ( {errors.paymentBufferDays && (
<small className="danger-text">{errors.paymentBufferDays.message}</small> <small className="danger-text">
)} {errors.paymentBufferDays.message}
</div> </small>
<div className="col-md-6"> )}
<Label htmlFor="numberOfIteration" className="form-label" required> </div>
Number of Iteration <div className="col-md-6">
</Label> <Label htmlFor="numberOfIteration" className="form-label" required>
<input Number of Iteration
type="number" </Label>
id="numberOfIteration" <input
className="form-control form-control-sm" type="number"
min="1" id="numberOfIteration"
step="1" className="form-control form-control-sm"
{...register("numberOfIteration", { valueAsNumber: true })} min="1"
placeholder="Enter number of iterations" step="1"
/> {...register("numberOfIteration", { valueAsNumber: true })}
{errors.numberOfIteration && ( placeholder="Enter number of iterations"
<small className="danger-text">{errors.numberOfIteration.message}</small> />
)} {errors.numberOfIteration && (
</div> <small className="danger-text">
</div> {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>
<div className="row my-2 text-start"> {/* <MultiEmployeeSearchInput
<div className="col-md-6">
<Label htmlFor="notifyTo" className="form-label" required>
Notify Employees
</Label>
{/* <MultiEmployeeSearchInput
control={control} control={control}
name="notifyTo" name="notifyTo"
projectId={watch("projectId")} projectId={watch("projectId")}
placeholder="Select Employees" placeholder="Select Employees"
forAll={true} forAll={true}
/> */} /> */}
<UsersTagInput
</div> control={control}
</div> name="notifyTo"
placeholder="Type to search users"
{/* Description */} projectId={watch("projectId")}
<div className="row my-2 text-start"> forAll={true}
<div className="col-md-12"> />
<Label htmlFor="description" className="form-label" required> </div>
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="col-md-6 mb-6">
<label for="TagifyUserList" class="form-label">Users List</label>
<input
id="TagifyUserList"
name="TagifyUserList"
class="form-control"
value="abatisse2@nih.gov, Justinian Hattersley" />
</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"
>
{createPending || isPending ? "Please wait...." : requestToEdit ? "Update":"Submit"}
</button>
</div>
</form>
</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">
{createPending || isPending
? "Please wait...."
: requestToEdit
? "Update"
: "Submit"}
</button>
</div>
</form>
</div>
);
} }
export default ManageRecurringExpense export default ManageRecurringExpense;

View File

@ -193,8 +193,8 @@ const RecurringExpenseList = ({ search, filterStatuses }) => {
</thead> </thead>
<tbody> <tbody>
{filteredData.length > 0 ? ( {filteredData?.length > 0 ? (
filteredData.map((recurringExpense) => ( filteredData?.map((recurringExpense) => (
<tr <tr
key={recurringExpense.id} key={recurringExpense.id}
className="align-middle" className="align-middle"
@ -265,7 +265,7 @@ const RecurringExpenseList = ({ search, filterStatuses }) => {
) : ( ) : (
<tr> <tr>
<td <td
colSpan={recurringExpenseColumns.length + 1} colSpan={recurringExpenseColumns?.length + 1}
className="text-center border-0 py-8" className="text-center border-0 py-8"
> >
<p>No Recurring Expense Found</p> <p>No Recurring Expense Found</p>

View File

@ -1,12 +1,12 @@
import { boolean, z } from "zod"; import { boolean, z } from "zod";
import { INR_CURRENCY_CODE } from "../../utils/constants"; import { INR_CURRENCY_CODE } from "../../utils/constants";
export const PaymentRecurringExpense = (expenseTypes) => { export const PaymentRecurringExpense = () => {
return z.object({ return z.object({
title: z.string().min(1, { message: "Title is required" }).transform((val) => val.trim()), 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()), 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()), 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()), notifyTo: z.array(z.string()).min(1,"Please select at lest one user"),
currencyId: z currencyId: z
.string() .string()
.min(1, { message: "Currency is required" }) .min(1, { message: "Currency is required" })
@ -77,7 +77,7 @@ export const defaultRecurringExpense = {
title: "", title: "",
description: "", description: "",
payee: "", payee: "",
notifyTo: "", notifyTo: [],
currencyId: "", currencyId: "",
amount: 0, amount: 0,
strikeDate: "", strikeDate: "",

View File

@ -143,10 +143,10 @@ const ViewRecurringExpense = ({ RecurringId }) => {
<div className="text-muted" style={{ textAlign: "left" }}> <div className="text-muted" style={{ textAlign: "left" }}>
{data?.notifyTo?.length > 0 {data?.notifyTo?.length > 0
? data.notifyTo.map((user, index) => ( ? data.notifyTo?.map((user, index) => (
<span key={user.id}> <span key={user.id}>
{user.email} {user.email}
{index < data.notifyTo.length - 1 && ", "} {index < data?.notifyTo?.length - 1 && ", "}
</span> </span>
)) ))
: "N/A"} : "N/A"}

View File

@ -1,87 +1,294 @@
import { useEffect, useRef } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { useController } from "react-hook-form"; import { useController } from "react-hook-form";
import { useEmployeesName } from "../../hooks/useEmployees";
import { useDebounce } from "../../utils/appUtils"; import { useDebounce } from "../../utils/appUtils";
import { useEmployeesName } from "../../hooks/useEmployees";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
const UsersTagInput = ({ control, name, projectId, placeholder, forAll }) => { const UsersTagInput = ({
const { field } = useController({ name, control }); control,
name,
placeholder,
projectId,
forAll,
isApplicationUser = false,
}) => {
const {
field: { value = [], onChange },
} = useController({ name, control });
const [search, setSearch] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const [filteredUsers, setFilteredUsers] = useState([]);
const [userCache, setUserCache] = useState({});
const dropdownRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const tagifyRef = useRef(null); const activeIndexRef = useRef(-1);
// debounce the search term const debouncedSearch = useDebounce(search, 300);
const debouncedSearch = useDebounce("", 400); const { data: employees, isLoading } = useEmployeesName(
const { data: employees } = useEmployeesName(projectId, debouncedSearch, forAll); projectId,
debouncedSearch,
forAll
);
// Keep both filtered list and cache updated
useEffect(() => { useEffect(() => {
if (!window.Tagify || !inputRef.current) return; if (employees?.data?.length) {
setFilteredUsers(employees.data);
activeIndexRef.current = -1;
// Initialize Tagify on the input // cache all fetched users by id
const Tagify = window.Tagify; setUserCache((prev) => {
tagifyRef.current = new Tagify(inputRef.current, { const updated = { ...prev };
enforceWhitelist: false, employees.data.forEach((u) => {
dropdown: { updated[u.id] = u;
enabled: 1, });
classname: "users-list", return updated;
searchKeys: ["value", "email"], });
}, } else {
templates: { setFilteredUsers([]);
dropdownItem: (tagData) => `
<div class="tagify__dropdown__item">
<div class="tagify__dropdown__item__avatar-wrap">
<img src="${tagData.avatar || '/default-avatar.png'}" alt="">
</div>
<strong>${tagData.value}</strong>
<span>${tagData.email || ""}</span>
</div>
`,
tag: (tagData) => `
<tag title="${tagData.value}" contenteditable="false" spellcheck="false" class="tagify__tag">
<div>
<span class="tagify__tag__avatar-wrap">
<img src="${tagData.avatar || '/default-avatar.png'}" alt="">
</span>
<span>${tagData.value}</span>
</div>
<x title="remove tag" class="tagify__tag__removeBtn" role="button"></x>
</tag>
`,
},
});
// Set default value (for editing case)
if (field.value?.length) {
tagifyRef.current.addTags(field.value);
}
// When tagify value changes, update form field
tagifyRef.current.on("change", (e) => {
const newVal = JSON.parse(e.detail.value || "[]");
field.onChange(newVal);
});
return () => tagifyRef.current.destroy();
}, []);
// Update whitelist whenever employees change
useEffect(() => {
if (tagifyRef.current && employees?.data) {
tagifyRef.current.settings.whitelist = employees.data.map((emp) => ({
value: `${emp.firstName} ${emp.lastName}`,
email: emp.email,
avatar: emp.avatarUrl || "",
id: emp.id,
}));
} }
}, [employees]); }, [employees]);
// close dropdown when clicking outside
useEffect(() => {
const onDocClick = (e) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target) &&
!inputRef.current.contains(e.target)
) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, []);
// select a user
const handleSelect = (user) => {
if (value.includes(user.id)) return;
const updated = [...value, user.id];
onChange(updated);
setSearch("");
setShowDropdown(false);
setTimeout(() => inputRef.current?.focus(), 0);
};
// remove selected user
const handleRemove = (id) => {
const updated = value.filter((uid) => uid !== id);
onChange(updated);
};
// keyboard navigation
const onInputKeyDown = (e) => {
if (!showDropdown) return;
const max = Math.max(0, filteredUsers.length - 1);
if (e.key === "ArrowDown") {
e.preventDefault();
activeIndexRef.current = Math.min(max, activeIndexRef.current + 1);
scrollToActive();
} else if (e.key === "ArrowUp") {
e.preventDefault();
activeIndexRef.current = Math.max(0, activeIndexRef.current - 1);
scrollToActive();
} else if (e.key === "Enter") {
e.preventDefault();
const idx = activeIndexRef.current;
if (idx >= 0 && filteredUsers[idx]) handleSelect(filteredUsers[idx]);
} else if (e.key === "Escape") {
setShowDropdown(false);
}
};
// scroll active dropdown item into view
const scrollToActive = () => {
const wrapper = dropdownRef.current?.querySelector(
".tagify__dropdown__wrapper"
);
const items = wrapper?.querySelectorAll(".tagify__dropdown__item");
const idx = activeIndexRef.current;
if (items && items[idx]) {
const item = items[idx];
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
if (wrapper.scrollTop > itemTop) wrapper.scrollTop = itemTop;
else if (wrapper.scrollTop + wrapper.clientHeight < itemBottom)
wrapper.scrollTop = itemBottom - wrapper.clientHeight;
}
};
// resolve user details by ID (for rendering tags)
const resolveUserById = (id) => {
return userCache[id] || filteredUsers.find((u) => u.id === id);
};
// main visible users list (memoized)
const visibleUsers = useMemo(() => {
const baseList = isApplicationUser
? (filteredUsers || []).filter((u) => u?.email)
: filteredUsers || [];
// also include selected users even if missing from current API
const selectedUsers =
Array.isArray(value) && value.length
? value.map((uid) => userCache[uid]).filter(Boolean)
: [];
// merge unique
const merged = [
...selectedUsers,
...baseList.filter((u) => !selectedUsers.some((s) => s.id === u.id)),
];
return merged;
}, [filteredUsers, isApplicationUser, value, userCache]);
return ( return (
<input <div
type="text" className="tagify form-control d-flex align-items-center flex-wrap position-relative "
ref={inputRef} ref={dropdownRef}
placeholder={placeholder || "Select users..."} >
className="form-control form-control-sm" {/* Selected tags (chips) */}
/> {value.map((id) => {
const u = resolveUserById(id);
if (!u) return null;
return (
<span
key={id}
className="tagify__tag d-inline-flex align-items-center me-1 mb-1"
role="listitem"
>
<div className="d-flex align-items-center">
{u.photo ? (
<span className="tagify__tag__avatar-wrap me-1">
<img
src={u.avatarUrl || "/default-avatar.png"}
alt={`${u.firstName || ""} ${u.lastName || ""}`}
style={{ width: 12, height: 12, objectFit: "cover" }}
/>
</span>
) : (
<div className="avatar avatar-xs me-2">
<span className="avatar-initial rounded-circle bg-label-secondary">
{u.firstName?.[0] || ""}
{u.lastName?.[0] || ""}
</span>
</div>
)}
<div className="d-flex flex-column">
<span className="tagify__tag-text">
{u.firstName} {u.lastName}
</span>
</div>
</div>
<button
type="button"
className="tagify__tag__removeBtn"
onClick={() => handleRemove(id)}
aria-label={`Remove ${u.firstName}`}
title="Remove"
/>
</span>
);
})}
<input
ref={inputRef}
type="text"
value={search}
id="TagifyUserList"
name="TagifyUserList"
className="tagify__input flex-grow-1 border-0 bg-transparent"
placeholder={placeholder || "Type to search users..."}
onChange={(e) => {
setSearch(e.target.value);
setShowDropdown(true);
}}
onFocus={() => {
setShowDropdown(true);
}}
onKeyDown={onInputKeyDown}
autoComplete="off"
aria-expanded={showDropdown}
aria-haspopup="listbox"
/>
{showDropdown && (
<div
className="tagify__dropdown users-list position-absolute w-100 shadow-sm rounded-2"
style={{
zIndex: 1050,
top: "100%",
left: 0,
marginTop: 6,
pointerEvents: "auto",
}}
role="listbox"
>
<div
className="tagify__dropdown__wrapper border rounded-2"
style={{
maxHeight: 200,
overflowY: "auto",
overflowX: "hidden",
scrollbarWidth: "thin",
}}
>
{isLoading ? (
<div className="py-6 px-2 text-center text-muted small">
Loading...
</div>
) : filteredUsers.length === 0 ? (
<div className="py-6 px-2 text-center text-muted small">
No users found
</div>
) : (
filteredUsers.map((user, idx) => {
const isActive = idx === activeIndexRef.current;
return (
<div
key={user.id}
role="option"
aria-selected={isActive}
tabIndex={0}
className={`tagify__dropdown__item ${
isActive ? "tagify__dropdown__item--active" : ""
}`}
onMouseEnter={() => (activeIndexRef.current = idx)}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(user);
}}
>
<div className="d-flex flex-row gap-2">
{user.photo ? (
<img
src={user.photo || "/default-avatar.png"}
alt={`${user.firstName || ""} ${user.lastName || ""}`}
/>
) : (
<Avatar
size="xs"
firstName={user.firstName}
lastName={user.lastName}
/>
)}
<strong>
{user.firstName} {user.lastName}
</strong>
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
); );
}; };

View File

@ -13,6 +13,7 @@ import Label from "../../components/common/Label";
import AdvancePaymentList from "../../components/AdvancePayment/AdvancePaymentList"; import AdvancePaymentList from "../../components/AdvancePayment/AdvancePaymentList";
import { employee } from "../../data/masters"; import { employee } from "../../data/masters";
import { formatFigure } from "../../utils/appUtils"; import { formatFigure } from "../../utils/appUtils";
import UsersTagInput from "../../components/common/usesInput";
export const AdvancePaymentContext = createContext(); export const AdvancePaymentContext = createContext();
export const useAdvancePaymentContext = () => { export const useAdvancePaymentContext = () => {
@ -26,7 +27,7 @@ export const useAdvancePaymentContext = () => {
}; };
const AdvancePaymentPage = () => { const AdvancePaymentPage = () => {
const [balance, setBalance] = useState(null); const [balance, setBalance] = useState(null);
const { control, reset, watch } = useForm({ const {control, reset, watch } = useForm({
defaultValues: { defaultValues: {
employeeId: "", employeeId: "",
}, },
@ -39,6 +40,8 @@ const AdvancePaymentPage = () => {
employeeId: selectedEmpoyee || "", employeeId: selectedEmpoyee || "",
}); });
}, [reset]); }, [reset]);
return ( return (
<AdvancePaymentContext.Provider value={{ setBalance }}> <AdvancePaymentContext.Provider value={{ setBalance }}>
<div className="container-fluid"> <div className="container-fluid">
@ -83,6 +86,8 @@ const AdvancePaymentPage = () => {
</div> </div>
</div> </div>
<AdvancePaymentList employeeId={selectedEmployeeId} /> <AdvancePaymentList employeeId={selectedEmployeeId} />
</div> </div>
</div> </div>
</AdvancePaymentContext.Provider> </AdvancePaymentContext.Provider>

View File

@ -145,13 +145,13 @@ const RecurringExpensePage = () => {
setViewRecurring({ IsOpen: null, recurringId: null }) setViewRecurring({ IsOpen: null, recurringId: null })
} }
> >
<viewRecurring {/* <viewRecurring
key={viewRecurring.RecurringId ?? "new"} key={viewRecurring.RecurringId ?? "new"}
closeModal={() => closeModal={() =>
setViewRecurring({ IsOpen: null, recurringId: null }) setViewRecurring({ IsOpen: null, recurringId: null })
} }
RecurringId={viewRecurring.recurringId} RecurringId={viewRecurring.recurringId}
/> /> */}
<ViewRecurringExpense RecurringId={viewRecurring.recurringId} /> <ViewRecurringExpense RecurringId={viewRecurring.recurringId} />
</GlobalModel> </GlobalModel>
)} )}