added recurring payment

This commit is contained in:
pramod.mahajan 2025-11-08 15:20:53 +05:30
parent 40cb641428
commit 5a7c6a29ba
14 changed files with 3063 additions and 0 deletions

View File

@ -46,6 +46,8 @@
<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" />
<link rel="stylesheet" href="/assets/vendor/libs/spinkit/spinkit.css" /> <link rel="stylesheet" href="/assets/vendor/libs/spinkit/spinkit.css" />
<link rel="stylesheet" href="/assets/vendor/libs/tagify/tagify.css" />
<link rel="stylesheet" href="/assets/vendor/libs/tagify/tagify.js" />
<!-- Helpers --> <!-- Helpers -->
<script src="/assets/vendor/js/helpers.js"></script> <script src="/assets/vendor/js/helpers.js"></script>

View File

@ -0,0 +1,879 @@
@charset "UTF-8";
:root {
--tagify-dd-color-primary: rgb(53,149,246);
--tagify-dd-bg-color: white;
--tagify-dd-item-pad: .3em .5em;
--tagify-dd-max-height: 300px;
}
.tagify {
--tags-disabled-bg: #F1F1F1;
--tags-border-color: #DDD;
--tags-hover-border-color: #CCC;
--tags-focus-border-color: #3595f6;
--tag-border-radius: 3px;
--tag-bg: rgba(167, 172, 178, 0.5);
--tag-hover: #D3E2E2;
--tag-text-color: black;
--tag-text-color--edit: black;
--tag-pad: 0.3em 0.5em;
--tag-inset-shadow-size: 2em;
--tag-invalid-color: #ff3e1d;
--tag-invalid-bg: rgba(255, 62, 29, 0.5);
--tag--min-width: 1ch;
--tag--max-width: auto;
--tag-hide-transition: 0.3s;
--tag-remove-bg: rgba(255, 62, 29, 0.3);
--tag-remove-btn-color: #7a838b;
--tag-remove-btn-bg: none;
--tag-remove-btn-bg--hover: #ff2804;
--input-color: inherit;
--placeholder-color: rgba(0, 0, 0, 0.4);
--placeholder-color-focus: rgba(0, 0, 0, 0.25);
--loader-size: .8em;
--readonly-striped: 1;
display: inline-flex;
align-items: flex-start;
flex-wrap: wrap;
border: 1px solid var(--tags-border-color);
padding: 0;
line-height: 0;
cursor: text;
outline: none;
position: relative;
box-sizing: border-box;
transition: 0.1s;
}
@keyframes tags--bump {
30% {
transform: scale(1.2);
}
}
@keyframes rotateLoader {
to {
transform: rotate(1turn);
}
}
.tagify:hover:not(.tagify--focus):not(.tagify--invalid) {
--tags-border-color: var(--tags-hover-border-color);
}
.tagify[disabled] {
background: var(--tags-disabled-bg);
filter: saturate(0);
opacity: 0.5;
pointer-events: none;
}
.tagify[readonly].tagify--select, .tagify[disabled].tagify--select {
pointer-events: none;
}
.tagify[readonly]:not(.tagify--mix):not(.tagify--select), .tagify[disabled]:not(.tagify--mix):not(.tagify--select) {
cursor: default;
}
.tagify[readonly]:not(.tagify--mix):not(.tagify--select) > .tagify__input, .tagify[disabled]:not(.tagify--mix):not(.tagify--select) > .tagify__input {
visibility: hidden;
width: 0;
margin: 5px 0;
}
.tagify[readonly]:not(.tagify--mix):not(.tagify--select) .tagify__tag > div, .tagify[disabled]:not(.tagify--mix):not(.tagify--select) .tagify__tag > div {
padding: var(--tag-pad);
}
.tagify[readonly]:not(.tagify--mix):not(.tagify--select) .tagify__tag > div::before, .tagify[disabled]:not(.tagify--mix):not(.tagify--select) .tagify__tag > div::before {
animation: readonlyStyles 1s calc(-1s * (var(--readonly-striped) - 1)) paused;
}
@keyframes readonlyStyles {
0% {
background: linear-gradient(45deg, var(--tag-bg) 25%, transparent 25%, transparent 50%, var(--tag-bg) 50%, var(--tag-bg) 75%, transparent 75%, transparent) 0/5px 5px;
box-shadow: none;
filter: brightness(0.95);
}
}
.tagify[readonly] .tagify__tag__removeBtn, .tagify[disabled] .tagify__tag__removeBtn {
display: none;
}
.tagify--loading .tagify__input > br:last-child {
display: none;
}
.tagify--loading .tagify__input::before {
content: none;
}
.tagify--loading .tagify__input::after {
content: "";
vertical-align: middle;
opacity: 1;
width: 0.7em;
height: 0.7em;
width: var(--loader-size);
height: var(--loader-size);
min-width: 0;
border: 3px solid;
border-color: #EEE #BBB #888 transparent;
border-radius: 50%;
animation: rotateLoader 0.4s infinite linear;
content: "" !important;
margin: -2px 0 -2px 0.5em;
}
.tagify--loading .tagify__input:empty::after {
margin-left: 0;
}
.tagify + input,
.tagify + textarea {
position: absolute !important;
left: -9999em !important;
transform: scale(0) !important;
}
.tagify__tag {
display: inline-flex;
align-items: center;
max-width: calc(var(--tag--max-width) - 10px);
margin-inline: 5px 0;
margin-block: 5px;
position: relative;
z-index: 1;
outline: none;
line-height: normal;
cursor: default;
transition: 0.13s ease-out;
}
.tagify__tag > div {
vertical-align: top;
box-sizing: border-box;
max-width: 100%;
padding: var(--tag-pad);
color: var(--tag-text-color);
line-height: inherit;
border-radius: var(--tag-border-radius);
white-space: nowrap;
transition: 0.13s ease-out;
}
.tagify__tag > div > * {
white-space: pre-wrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: top;
min-width: var(--tag--min-width);
max-width: var(--tag--max-width);
transition: 0.8s ease, 0.1s color;
}
.tagify__tag > div > *[contenteditable] {
outline: none;
user-select: text;
cursor: text;
margin: -2px;
padding: 2px;
max-width: 350px;
}
.tagify__tag > div::before {
content: "";
position: absolute;
border-radius: inherit;
inset: var(--tag-bg-inset, 0);
z-index: -1;
pointer-events: none;
transition: 120ms ease;
animation: tags--bump 0.3s ease-out 1;
box-shadow: 0 0 0 var(--tag-inset-shadow-size) var(--tag-bg) inset;
}
.tagify__tag:hover:not([readonly]) div::before, .tagify__tag:focus div::before {
--tag-bg-inset: -2.5px;
--tag-bg: var(--tag-hover);
}
.tagify__tag--loading {
pointer-events: none;
}
.tagify__tag--loading .tagify__tag__removeBtn {
display: none;
}
.tagify__tag--loading::after {
--loader-size: .4em;
content: "";
vertical-align: middle;
opacity: 1;
width: 0.7em;
height: 0.7em;
width: var(--loader-size);
height: var(--loader-size);
min-width: 0;
border: 3px solid;
border-color: #EEE #BBB #888 transparent;
border-radius: 50%;
animation: rotateLoader 0.4s infinite linear;
margin: 0 0.5em 0 -0.1em;
}
.tagify__tag--flash div::before {
animation: none;
}
.tagify__tag--hide {
width: 0 !important;
padding-left: 0;
padding-right: 0;
margin-left: 0;
margin-right: 0;
opacity: 0;
transform: scale(0);
transition: var(--tag-hide-transition);
pointer-events: none;
}
.tagify__tag--hide > div > * {
white-space: nowrap;
}
.tagify__tag.tagify--noAnim > div::before {
animation: none;
}
.tagify__tag.tagify--notAllowed:not(.tagify__tag--editable) div > span {
opacity: 0.5;
}
.tagify__tag.tagify--notAllowed:not(.tagify__tag--editable) div::before {
--tag-bg: var(--tag-invalid-bg);
transition: 0.2s;
}
.tagify__tag[readonly] .tagify__tag__removeBtn {
display: none;
}
.tagify__tag[readonly] > div::before {
animation: readonlyStyles 1s calc(-1s * (var(--readonly-striped) - 1)) paused;
}
@keyframes readonlyStyles {
0% {
background: linear-gradient(45deg, var(--tag-bg) 25%, transparent 25%, transparent 50%, var(--tag-bg) 50%, var(--tag-bg) 75%, transparent 75%, transparent) 0/5px 5px;
box-shadow: none;
filter: brightness(0.95);
}
}
.tagify__tag--editable > div {
color: var(--tag-text-color--edit);
}
.tagify__tag--editable > div::before {
box-shadow: 0 0 0 2px var(--tag-hover) inset !important;
}
.tagify__tag--editable > .tagify__tag__removeBtn {
pointer-events: none;
}
.tagify__tag--editable > .tagify__tag__removeBtn::after {
opacity: 0;
transform: translateX(100%) translateX(5px);
}
.tagify__tag--editable.tagify--invalid > div::before {
box-shadow: 0 0 0 2px var(--tag-invalid-color) inset !important;
}
.tagify__tag__removeBtn {
order: 5;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50px;
cursor: pointer;
font: 14px/1 Arial;
background: var(--tag-remove-btn-bg);
color: var(--tag-remove-btn-color);
width: 14px;
height: 14px;
margin-inline: auto 4.6666666667px;
overflow: hidden;
transition: 0.2s ease-out;
}
.tagify__tag__removeBtn::after {
content: "×";
transition: 0.3s, color 0s;
}
.tagify__tag__removeBtn:hover {
color: white;
background: var(--tag-remove-btn-bg--hover);
}
.tagify__tag__removeBtn:hover + div > span {
opacity: 0.5;
}
.tagify__tag__removeBtn:hover + div::before {
box-shadow: 0 0 0 var(--tag-inset-shadow-size) var(--tag-remove-bg, rgba(255, 62, 29, 0.3)) inset !important;
transition: box-shadow 0.2s;
}
.tagify:not(.tagify--mix) .tagify__input br {
display: none;
}
.tagify:not(.tagify--mix) .tagify__input * {
display: inline;
white-space: nowrap;
}
.tagify__input {
flex-grow: 1;
display: inline-block;
min-width: 110px;
margin: 5px;
padding: var(--tag-pad);
line-height: normal;
position: relative;
white-space: pre-wrap;
color: var(--input-color);
box-sizing: inherit;
/* Seems firefox newer versions don't need this any more
@supports ( -moz-appearance:none ){
&::before{
line-height: inherit;
position:relative;
}
}
*/
}
@-moz-document url-prefix() {}
.tagify__input:empty::before {
position: static;
}
.tagify__input:focus {
outline: none;
}
.tagify__input:focus::before {
transition: 0.2s ease-out;
opacity: 0;
transform: translatex(6px);
/* ALL MS BROWSERS: hide placeholder (on focus) otherwise the caret is placed after it, which is weird */
/* IE Edge 12+ CSS styles go here */
}
@supports (-ms-ime-align: auto) {
.tagify__input:focus::before {
display: none;
}
}
.tagify__input:focus:empty::before {
transition: 0.2s ease-out;
opacity: 1;
transform: none;
color: rgba(0, 0, 0, 0.25);
color: var(--placeholder-color-focus);
}
@-moz-document url-prefix() {
.tagify__input:focus:empty::after {
display: none;
}
}
.tagify__input::before {
content: attr(data-placeholder);
height: 1em;
line-height: 1em;
margin: auto 0;
z-index: 1;
color: var(--placeholder-color);
white-space: nowrap;
pointer-events: none;
opacity: 0;
position: absolute;
}
.tagify__input::after {
content: attr(data-suggest);
display: inline-block;
vertical-align: middle;
position: absolute;
min-width: calc(100% - 1.5em);
text-overflow: ellipsis;
overflow: hidden;
white-space: pre; /* allows spaces at the beginning */
color: var(--tag-text-color);
opacity: 0.3;
pointer-events: none;
max-width: 100px;
}
.tagify__input .tagify__tag {
margin: 0 1px;
}
.tagify--mix {
display: block;
}
.tagify--mix .tagify__input {
padding: 5px;
margin: 0;
width: 100%;
height: 100%;
line-height: 1.5;
display: block;
}
.tagify--mix .tagify__input::before {
height: auto;
display: none;
line-height: inherit;
}
.tagify--mix .tagify__input::after {
content: none;
}
.tagify--select::after {
content: ">";
opacity: 0.5;
position: absolute;
top: 50%;
right: 0;
bottom: 0;
font: 16px monospace;
line-height: 8px;
height: 8px;
pointer-events: none;
transform: translate(-150%, -50%) scaleX(1.2) rotate(90deg);
transition: 0.2s ease-in-out;
}
.tagify--select[aria-expanded=true]::after {
transform: translate(-150%, -50%) rotate(270deg) scaleY(1.2);
}
.tagify--select .tagify__tag {
position: absolute;
top: 0;
right: 1.8em;
bottom: 0;
}
.tagify--select .tagify__tag div {
display: none;
}
.tagify--select .tagify__input {
width: 100%;
}
.tagify--empty .tagify__input::before {
transition: 0.2s ease-out;
opacity: 1;
transform: none;
display: inline-block;
width: auto;
}
.tagify--mix .tagify--empty .tagify__input::before {
display: inline-block;
}
.tagify--focus {
--tags-border-color: var(--tags-focus-border-color);
transition: 0s;
}
.tagify--invalid {
--tags-border-color: #ff3e1d;
}
.tagify__dropdown {
position: absolute;
z-index: 9999;
transform: translateY(-1px);
border-top: 1px solid var(--tagify-dd-color-primary);
overflow: hidden;
}
.tagify__dropdown[dir=rtl] {
transform: translate(-100%, -1px);
}
.tagify__dropdown[placement=top] {
margin-top: 0;
transform: translateY(-100%);
}
.tagify__dropdown[placement=top] .tagify__dropdown__wrapper {
border-top-width: 1.1px;
border-bottom-width: 0;
}
.tagify__dropdown[position=text] {
box-shadow: 0 0 0 3px rgba(var(--tagify-dd-color-primary), 0.1);
font-size: 0.9em;
}
.tagify__dropdown[position=text] .tagify__dropdown__wrapper {
border-width: 1px;
}
.tagify__dropdown__wrapper {
max-height: var(--tagify-dd-max-height);
overflow: hidden;
overflow-x: hidden;
background: var(--tagify-dd-bg-color);
border: 1px solid;
border-color: var(--tagify-dd-color-primary);
border-bottom-width: 1.5px;
border-top-width: 0;
box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.2);
transition: 0.3s cubic-bezier(0.5, 0, 0.3, 1), transform 0.15s;
animation: dd-wrapper-show 0s 0.3s forwards;
}
@keyframes dd-wrapper-show {
to {
overflow-y: auto;
}
}
.tagify__dropdown__header:empty {
display: none;
}
.tagify__dropdown__footer {
display: inline-block;
margin-top: 0.5em;
padding: var(--tagify-dd-item-pad);
font-size: 0.7em;
font-style: italic;
opacity: 0.5;
}
.tagify__dropdown__footer:empty {
display: none;
}
.tagify__dropdown--initial .tagify__dropdown__wrapper {
max-height: 20px;
transform: translateY(-1em);
}
.tagify__dropdown--initial[placement=top] .tagify__dropdown__wrapper {
transform: translateY(2em);
}
.tagify__dropdown__item {
box-sizing: border-box;
padding: var(--tagify-dd-item-pad);
margin: 1px;
white-space: pre-wrap;
cursor: pointer;
border-radius: 2px;
position: relative;
outline: none;
max-height: 60px;
max-width: 100%;
/* custom hidden transition effect is needed for horizontal-layout suggestions */
}
.tagify__dropdown__item--active {
background: var(--tagify-dd-color-primary);
color: white;
}
.tagify__dropdown__item:active {
filter: brightness(105%);
}
.tagify__dropdown__item--hidden {
padding-top: 0;
padding-bottom: 0;
margin: 0 1px;
pointer-events: none;
overflow: hidden;
max-height: 0;
transition: var(--tagify-dd-item--hidden-duration, 0.3s) !important;
}
.tagify__dropdown__item--hidden > * {
transform: translateY(-100%);
opacity: 0;
transition: inherit;
}
/* Suggestions items */
.tagify__dropdown.users-list {
font-size: 1rem;
}
.tagify__dropdown.users-list .addAll {
display: block !important;
}
.tagify__dropdown.users-list .tagify__dropdown__item {
padding: 0.5em 0.7em;
display: grid;
grid-template-columns: auto 1fr;
gap: 0 1em;
grid-template-areas: "avatar name" "avatar email";
}
.tagify__dropdown.users-list .tagify__dropdown__item__avatar-wrap {
grid-area: avatar;
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
transition: 0.1s ease-out;
}
.tagify__dropdown.users-list img {
width: 100%;
vertical-align: top;
}
.tagify__dropdown.users-list strong {
grid-area: name;
width: 100%;
align-self: center;
font-weight: 500;
}
.tagify__dropdown.users-list span {
grid-area: email;
width: 100%;
font-size: 0.9em;
opacity: 0.6;
}
/* Tags items */
.tagify__tag {
white-space: nowrap;
}
.tagify__tag .tagify__tag__avatar-wrap {
width: 22px;
height: 22px;
white-space: normal;
border-radius: 50%;
margin-right: 5px;
transition: 0.12s ease-out;
vertical-align: middle;
}
.tagify__tag img {
width: 100%;
vertical-align: top;
}
[dir=rtl] .tagify__tag .tagify__tag__avatar-wrap {
margin-left: 5px;
margin-right: auto;
}
.light-style .tagify__dropdown.users-list .tagify__dropdown__item__avatar-wrap {
background: #f5f5f9;
}
.light-style .tagify__tag .tagify__tag__avatar-wrap {
background: #f5f5f9;
}
.light-style .tagify__dropdown.users-list .addAll {
border-bottom: 1px solid #e4e6e8;
}
.dark-style .tagify__dropdown.users-list .tagify__dropdown__item__avatar-wrap {
background: #232333;
}
.dark-style .tagify__tag .tagify__tag__avatar-wrap {
background: #232333;
}
.dark-style .tagify__dropdown.users-list .addAll {
border-bottom: 1px solid #4e4f6c;
}
.tags-inline .tagify__dropdown__wrapper {
padding: 0 0.4375rem 0.4375rem 0.4375rem;
}
.tags-inline .tagify__dropdown__item {
display: inline-block;
border-radius: 3px;
padding: 0.3em 0.5em;
margin: 0.4375rem 0.4375rem 0 0;
font-size: 0.85em;
transition: 0s;
}
[dir=rtl] .tags-inline .tagify__dropdown__item {
margin: 0.4375rem 0 0 0.4375rem;
}
.light-style .tags-inline .tagify__dropdown__item {
border: 1px solid #e4e6e8;
color: #646e78;
}
.dark-style .tags-inline .tagify__dropdown__item {
border: 1px solid #4e4f6c;
color: #b2b2c4;
}
.tagify-email-list {
display: inline-block;
min-width: 0;
border: none;
/* Do not show the "remove tag" (x) button when only a single tag remains */
}
.tagify-email-list.tagify {
padding: 0 !important;
padding-bottom: calc(0.4375rem - var(--bs-border-width)) !important;
}
.tagify-email-list.tagify {
padding: 0 !important;
padding-bottom: calc(0.4375rem - var(--bs-border-width)) !important;
}
.tagify-email-list.tagify.tagify--focus {
padding-left: 0 !important;
}
.tagify-email-list .tagify__tag {
margin: 0;
margin-inline-start: 0 !important;
margin-inline-end: 0.625rem !important;
margin-bottom: 0.4375rem !important;
}
.tagify-email-list .tagify__tag > div {
padding: 0.21875rem 0.4375rem !important;
padding-inline: 0.875rem !important;
}
.tagify-email-list .tagify__tag:only-of-type > div {
padding-inline: 0.4375rem !important;
}
.tagify-email-list .tagify__tag:only-of-type .tagify__tag__removeBtn {
display: none;
}
.tagify-email-list .tagify__tag__removeBtn {
opacity: 0;
transform: translateX(-6px) scale(0.5);
margin-left: -3ch;
transition: 0.12s;
position: absolute;
inset-inline-end: 0;
}
.tagify-email-list .tagify__tag:hover .tagify__tag__removeBtn {
transform: none;
opacity: 1;
margin-left: -1ch;
}
.tagify-email-list .tagify__input {
display: none;
}
.tagify__tag > div {
border-radius: 50rem;
}
[dir=rtl] .tagify-email-list .tagify__tag {
margin: 0 0.4375rem 0.4375rem 0;
}
[dir=rtl] .tagify-email-list .tagify__tag:hover .tagify__tag__removeBtn {
margin-left: auto;
margin-right: -1ch;
}
[dir=rtl] .tagify-email-list .tagify__tag__removeBtn {
transform: translateX(6px) scale(0.5);
margin-left: auto;
margin-right: -3ch;
}
.light-style .tagify-email-list .tagify__tag--editable:not(.tagify--invalid) > div::before {
box-shadow: 0 0 0 2px #e4e6e8 inset !important;
}
.dark-style .tagify-email-list .tagify__tag--editable:not(.tagify--invalid) > div::before {
box-shadow: 0 0 0 2px #4e4f6c inset !important;
}
.tagify.form-control {
transition: none;
display: flex;
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.2rem !important;
}
.fv-plugins-bootstrap5-row-invalid .tagify.form-control {
padding: 0 calc(0.4375rem - var(--bs-border-width)) calc(0.4375rem - 2px) !important;
}
.tagify.tagify--focus, .tagify.form-control:focus {
padding: 0 calc(0.4375rem - var(--bs-border-width)) 0.3606rem !important;
border-width: 2px;
}
.tagify__tag, .tagify__input {
margin: 0.1875rem 0.625rem 0 0 !important;
line-height: 1;
}
.tagify__input {
line-height: 1.5rem;
}
.tagify__input:empty::before {
top: 4px;
}
.tagify__tag > div {
line-height: 1.5rem;
padding: 0 0 0 0.4375rem;
}
.tagify__tag__removeBtn {
margin-right: 0.1375rem;
margin-left: 0.21875rem;
font-family: "boxicons";
font-size: 1rem;
opacity: 0.7;
}
.tagify__tag__removeBtn:hover {
background: none;
color: #ff2804 !important;
}
.tagify__tag__removeBtn::after {
content: "\ef06";
}
.tagify__tag:hover:not([readonly]) div::before, .tagify__tag:focus div::before {
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
}
.tagify__dropdown {
transform: translateY(0);
}
.tagify[readonly]:not(.tagify--mix) .tagify__tag > div {
padding: 0 0.4375rem 0 0.4375rem !important;
}
.tagify__input {
padding: 0;
}
.tagify__tag-text {
font-size: 0.8125rem;
font-weight: 500;
}
.tagify.form-control {
padding-top: 0.1412rem !important;
}
.tagify.tagify--focus, .tagify.form-control:focus {
padding-top: calc(0.1412rem - 1px) !important;
}
.tagify__tag__removeBtn {
margin-inline-end: 0.3rem;
}
[dir=rtl] .tagify__tag, [dir=rtl] .tagify__input {
margin: 0.4375rem 0 0 0.4375rem;
}
[dir=rtl] .tagify + input,
[dir=rtl] .tagify + textarea {
left: 0;
right: -9999em !important;
}
[dir=rtl] .tagify__tag > div {
padding: 0 0.6875rem 0 0;
}
[dir=rtl] .tagify__tag__removeBtn {
margin-left: 0.4375rem;
margin-right: 0.21875rem;
}
.light-style .tagify__tag > div::before {
box-shadow: 0 0 0 1.3em rgba(34, 48, 62, 0.08) inset;
}
.light-style .tagify__tag .tagify__tag-text {
color: #384551;
}
.light-style .tagify__tag:hover:not([readonly]) div::before, .light-style .tagify__tag:focus div::before {
box-shadow: 0 0 0 1.3em rgba(34, 48, 62, 0.12) inset;
}
.light-style .tagify__tag__removeBtn {
color: #7a838b;
}
.light-style .tagify__tag__removeBtn:hover + div::before {
background: rgba(255, 62, 29, 0.3);
}
.light-style .tagify:hover:not([readonly]) {
border-color: #ced1d5;
}
.light-style .tagify__input::before {
color: #a7acb2 !important;
}
.light-style .tagify__dropdown {
box-shadow: 0 0.25rem 0.75rem 0 rgba(34, 48, 62, 0.14);
border-top-color: #e4e6e8;
}
.light-style .tagify__dropdown__wrapper {
background: #fff;
border-color: #e4e6e8;
}
.dark-style .tagify__tag > div::before {
box-shadow: 0 0 0 1.3em rgba(230, 230, 241, 0.08) inset;
}
.dark-style .tagify__tag > div .tagify__tag-text {
color: #d5d5e2;
}
.dark-style .tagify__tag:hover:not([readonly]) div::before, .dark-style .tagify__tag:focus div::before {
box-shadow: 0 0 0 1.3em rgba(230, 230, 241, 0.12) inset;
}
.dark-style .tagify__tag__removeBtn {
color: #a1a1b5;
}
.dark-style .tagify__tag__removeBtn:hover + div::before {
background: rgba(255, 62, 29, 0.3);
}
.dark-style .tagify:hover:not([readonly]) {
border-color: #5f607b;
}
.dark-style .tagify__input::before {
color: #7e7f96 !important;
}
.dark-style .tagify[readonly]:not(.tagify--mix) .tagify__tag > div::before {
background: linear-gradient(45deg, #5f607b 25%, transparent 25%, transparent 50%, #5f607b 50%, #5f607b 75%, transparent 75%, transparent) 0/5px 5px;
}
.dark-style .tagify[readonly]:not(.tagify--mix):not(.tagify--select) .tagify__tag > div::before {
animation: none;
box-shadow: none;
}
.dark-style .tagify__dropdown {
box-shadow: 0 0.25rem 0.75rem 0 rgba(20, 20, 29, 0.24);
border-top-color: #4e4f6c;
}
.dark-style .tagify__dropdown__wrapper {
box-shadow: 0 0.25rem 0.75rem 0 rgba(20, 20, 29, 0.24);
background: #2b2c40;
border-color: #4e4f6c;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,537 @@
import React, { useEffect, useState } from "react";
import Label from "../common/Label";
import { Controller, useForm } from "react-hook-form";
import {
useCurrencies,
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 { useProjectName } from "../../hooks/useProjects";
import {
useCreateRecurringExpense,
usePayee,
useRecurringExpenseDetail,
useUpdateRecurringExpense,
} from "../../hooks/useExpense";
import InputSuggestions from "../common/InputSuggestion";
import { useEmployeesName } from "../../hooks/useEmployees";
import PmsEmployeeInputTag from "../common/PmsEmployeeInputTag";
const ManageRecurringExpense = ({ closeModal, requestToEdit = null }) => {
const {
data,
isLoading,
isError,
error: requestError,
} = useRecurringExpenseDetail(requestToEdit);
const { data: employees } = useEmployeesName(null, null, true);
//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 schema = PaymentRecurringExpense();
const {
register,
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 { mutate: RecurringExpenseUpdate, isPending } =
useUpdateRecurringExpense(() => handleClose());
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) => {
console.log(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);
}
};
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>
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 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>}
{!statusLoading &&
!statusError &&
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}
/> */}
<PmsEmployeeInputTag
control={control}
name="notifyTo"
placeholder="Type to search users"
projectId={watch("projectId")}
forAll={true}
/>
{errors.notifyTo && (
<small className="danger-text">{errors.notifyTo.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"
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;

View File

@ -0,0 +1,290 @@
import React, { useState } from "react";
import {
EXPENSE_DRAFT,
EXPENSE_REJECTEDBY,
FREQUENCY_FOR_RECURRING,
ITEMS_PER_PAGE,
PAYEE_RECURRING_EXPENSE,
} 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";
import Pagination from "../common/Pagination";
const RecurringExpenseList = ({ search, filterStatuses }) => {
const { setManageRequest, setVieRequest, setViewRecurring } = useRecurringExpenseContext();
const navigate = useNavigate();
const [IsDeleteModalOpen, setIsDeleteModalOpen,] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const SelfId = useSelector(
(store) => store?.globalVariables?.loginUser?.employeeInfo?.id
);
const statusColorMap = {
"da462422-13b2-45cc-a175-910a225f6fc8": "primary", // Active
"306856fb-5655-42eb-bf8b-808bb5e84725": "success", // Completed
"3ec864d2-8bf5-42fb-ba70-5090301dd816": "danger", // De-Activated
"8bfc9346-e092-4a80-acbf-515ae1ef6868": "warning", // Paused
};
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.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-center",
getValue: (e) => {
const color = statusColorMap[e?.status?.id] || "secondary";
const label = PAYEE_RECURRING_EXPENSE.find(
(s) => s.id === e?.status?.id
)?.label;
return (
<span className={`badge bg-label-${color}`}>
{label || e?.status?.name || "N/A"}
</span>
);
},
},
];
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 paginate = (page) => {
if (page >= 1 && page <= (data?.totalPages ?? 1)) {
setCurrentPage(page);
}
};
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 = data?.data?.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="Under the working"
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 flex-row gap-2 gap-0">
<i
className="bx bx-show text-primary cursor-pointer"
onClick={() =>
setViewRecurring({
recurringId: recurringExpense?.id,
view: true,
})
}
></i>
<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 */}
<Pagination
currentPage={currentPage}
totalPages={data?.totalPages}
onPageChange={paginate}
/>
</div>
</>
);
};
export default RecurringExpenseList;

View File

@ -0,0 +1,112 @@
import { boolean, z } from "zod";
import { INR_CURRENCY_CODE } from "../../utils/constants";
export const PaymentRecurringExpense = () => {
return z.object({
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.array(z.string()).min(1,"Please select at lest one user"),
currencyId: z
.string()
.min(1, { message: "Currency is required" })
.transform((val) => val.trim()),
amount: z
.number({
required_error: "Amount is required",
invalid_type_error: "Amount must be a number",
})
.min(1, { message: "Amount must be greater than 0" })
.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" })
.refine((val) => !isNaN(Date.parse(val)), {
message: "Invalid date format",
})
.transform((val) => val.trim()),
projectId: z
.string()
.min(1, { message: "Project is required" })
.transform((val) => val.trim()),
paymentBufferDays: z
.number({
required_error: "Buffer days is required",
invalid_type_error: "Buffer days must be a number",
})
.min(0, { message: "Buffer days cannot be negative" }),
numberOfIteration: z
.number({
required_error: "Iteration is required",
invalid_type_error: "Iteration must be a number",
})
.min(1, { message: "Iteration must be at least 1" }),
expenseCategoryId: z
.string()
.min(1, { message: "Expense Category is required" })
.transform((val) => val.trim()),
statusId: z
.string()
.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",
})
.refine((val) => [0, 1, 2, 3, 4, 5].includes(val), {
message: "Invalid frequency selected",
}),
isVariable: z.boolean().optional(),
});
};
export const defaultRecurringExpense = {
title: "",
description: "",
payee: "",
notifyTo: [],
currencyId: "",
amount: 0,
strikeDate: "",
projectId: "",
paymentBufferDays: 0,
numberOfIteration: 1,
expenseCategoryId: "",
statusId: "",
frequency: 1,
isVariable: true,
};
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

@ -0,0 +1,342 @@
import React, { useState } from "react";
import {
EXPENSE_DRAFT,
EXPENSE_REJECTEDBY,
ITEMS_PER_PAGE,
} from "../../utils/constants";
import {
formatCurrency,
getColorNameFromHex,
useDebounce,
} from "../../utils/appUtils";
import { usePaymentRequestList } from "../../hooks/useExpense";
import { formatDate, formatUTCToLocalTime } from "../../utils/dateUtils";
import Avatar from "../../components/common/Avatar";
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";
const RecurringExpenseList = ({ filters, groupBy = "submittedBy", search }) => {
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 groupByField = (items, field) => {
return items.reduce((acc, item) => {
let key;
let displayField;
switch (field) {
case "transactionDate":
key = item?.transactionDate?.split("T")[0];
displayField = "Transaction Date";
break;
case "status":
key = item?.status?.displayName || "Unknown";
displayField = "Status";
break;
case "submittedBy":
key = `${item?.createdBy?.firstName ?? ""} ${item.createdBy?.lastName ?? ""
}`.trim();
displayField = "Submitted By";
break;
case "project":
key = item?.project?.name || "Unknown Project";
displayField = "Project";
break;
case "paymentMode":
key = item?.paymentMode?.name || "Unknown Mode";
displayField = "Payment Mode";
break;
case "expensesType":
key = item?.expensesType?.name || "Unknown Type";
displayField = "Expense Category";
break;
case "createdAt":
key = item?.createdAt?.split("T")[0] || "Unknown Date";
displayField = "Created Date";
break;
default:
key = "Others";
displayField = "Others";
}
const groupKey = `${field}_${key}`; // unique key for object property
if (!acc[groupKey]) {
acc[groupKey] = { key, displayField, items: [] };
}
acc[groupKey].items.push(item);
return acc;
}, {});
};
const paymentRequestColumns = [
{
key: "paymentRequestUID",
label: "Template Name",
align: "text-start mx-2",
getValue: (e) => e.paymentRequestUID || "N/A",
},
{
key: "title",
label: "Frequency",
align: "text-start",
getValue: (e) => e.title || "N/A",
},
{
key: "createdAt",
label: "Next Generation Date",
align: "text-start",
getValue: (e) => formatUTCToLocalTime(e?.createdAt),
},
{
key: "createdAt",
label: "Status",
align: "text-start",
getValue: (e) => formatUTCToLocalTime(e?.createdAt),
},
];
const [currentPage, setCurrentPage] = useState(1);
const debouncedSearch = useDebounce(search, 500);
const { data, isLoading, isError, error, isRefetching, refetch } =
usePaymentRequestList(
ITEMS_PER_PAGE,
currentPage,
filters,
true,
debouncedSearch
);
const paymentRequestData = data?.data || [];
const totalPages = data?.data?.totalPages || 1;
if (isError) {
return <Error error={error} isFeteching={isRefetching} refetch={refetch} />;
}
const header = [
"Request ID",
"Request Title",
"Submitted By",
"Submitted On",
"Amount",
"Status",
"Action",
];
if (isLoading) return <ExpenseTableSkeleton headers={header} />;
const grouped = groupBy
? groupByField(data?.data ?? [], groupBy)
: { All: data?.data ?? [] };
const IsGroupedByDate = [
{ key: "transactionDate", displayField: "Transaction Date" },
{ key: "createdAt", displayField: "created Date" },
]?.includes(groupBy);
const canEditExpense = (paymentRequest) => {
return (
(paymentRequest?.expenseStatus?.id === EXPENSE_DRAFT ||
EXPENSE_REJECTEDBY.includes(paymentRequest?.expenseStatus.id)) &&
paymentRequest?.createdBy?.id === SelfId
);
};
const canDetetExpense = (request) => {
return (
request?.expenseStatus?.id === EXPENSE_DRAFT &&
request?.createdBy?.id === SelfId
);
};
const handleDelete = (id) => {
setDeletingId(id);
DeleteExpense(
{ id },
{
onSettled: () => {
setDeletingId(null);
setIsDeleteModalOpen(false);
},
}
);
};
return (
<>
{IsDeleteModalOpen && (
<ConfirmModal
isOpen={IsDeleteModalOpen}
type="delete"
header="Delete Expense"
message="Are you sure you want delete?"
onSubmit={handleDelete}
onClose={() => setIsDeleteModalOpen(false)}
// loading={isPending}
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>
{paymentRequestColumns.map((col) => (
<th key={col.key} className={`sorting ${col.align}`}>
{col.label}
</th>
))}
<th className="text-center">Action</th>
</tr>
</thead>
<tbody>
{Object.keys(grouped).length > 0 ? (
Object.values(grouped).map(({ key, displayField, items }) => (
<React.Fragment key={key}>
<tr className="tr-group text-dark">
<td colSpan={8} className="text-start">
<div className="d-flex align-items-center">
{" "}
<small className="fs-6 py-1">
{displayField} :{" "}
</small>{" "}
<small className="fs-6 ms-3">
{IsGroupedByDate ? formatUTCToLocalTime(key) : key}
</small>
</div>
</td>
</tr>
{items?.map((paymentRequest) => (
<tr key={paymentRequest.id}>
{paymentRequestColumns.map(
(col) =>
(col.isAlwaysVisible || groupBy !== col.key) && (
<td
key={col.key}
className={`d-table-cell ${col.align ?? ""}`}
>
{col?.customRender
? col?.customRender(paymentRequest)
: col?.getValue(paymentRequest)}
</td>
)
)}
<td className="sticky-action-column bg-white">
<div className="d-flex justify-content-center gap-2">
<i
className="bx bx-show text-primary cursor-pointer"
onClick={() =>
setVieRequest({
requestId: paymentRequest.id,
view: true,
})
}
></i>
{canEditExpense(paymentRequest) && (
<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"
aria-expanded="false"
>
<i
className="bx bx-dots-vertical-rounded text-muted p-0"
data-bs-toggle="tooltip"
data-bs-offset="0,8"
data-bs-placement="top"
data-bs-custom-class="tooltip-dark"
title="More Action"
></i>
</button>
<ul className="dropdown-menu dropdown-menu-end w-auto">
<li
onClick={() =>
setManageRequest({
IsOpen: true,
RequestId: paymentRequest.id,
})
}
>
<a className="dropdown-item px-2 cursor-pointer py-1">
<i className="bx bx-edit text-primary bx-xs me-2"></i>
<span className="align-left ">
Modify
</span>
</a>
</li>
{canDetetExpense(paymentRequest) && (
<li
onClick={() => {
setIsDeleteModalOpen(true);
setDeletingId(paymentRequest.id);
}}
>
<a className="dropdown-item px-2 cursor-pointer py-1">
<i className="bx bx-trash text-danger bx-xs me-2"></i>
<span className="align-left">
Delete
</span>
</a>
</li>
)}
</ul>
</div>
)}
</div>
</td>
</tr>
))}
</React.Fragment>
))
) : (
<tr>
<td colSpan={8} className="text-center border-0 ">
<div className="py-8">
<p>No Request Found</p>
</div>
</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

@ -0,0 +1,307 @@
import React from 'react'
import { useRecurringExpenseDetail } from '../../hooks/useExpense';
import { formatUTCToLocalTime } from '../../utils/dateUtils';
import { formatFigure, getColorNameFromHex } from '../../utils/appUtils';
import Avatar from '../common/Avatar';
import { FREQUENCY_FOR_RECURRING } from '../../utils/constants';
import { ExpenseDetailsSkeleton } from '../Expenses/ExpenseSkeleton';
const ViewRecurringExpense = ({ RecurringId }) => {
const { data, isLoading, isError, error, isFetching } = useRecurringExpenseDetail(RecurringId);
const statusColorMap = {
"da462422-13b2-45cc-a175-910a225f6fc8": "primary", // Active
"306856fb-5655-42eb-bf8b-808bb5e84725": "success", // Completed
"3ec864d2-8bf5-42fb-ba70-5090301dd816": "danger", // De-Activated
"8bfc9346-e092-4a80-acbf-515ae1ef6868": "warning", // Paused
};
if (isLoading) return <ExpenseDetailsSkeleton />;
return (
<form className="container px-3">
<div className="col-12 mb-1">
<h5 className="fw-semibold m-0">Recurring Payment Details</h5>
</div>
<div className="row mb-1">
{/* <div className="col-12 col-lg-7 col-xl-8 mb-3"> */}
<div className="row">
{/* Row 1 Recurring Id and Status */}
<div className="col-12 d-flex justify-content-between text-start fw-semibold my-2 mb-4">
<span>{data?.recurringPaymentUID}</span>
<span
className={`badge bg-label-${statusColorMap[data?.status?.id] || "secondary"}`}
>
{data?.status?.name || "N/A"}
</span>
</div>
{/* Row 2 Category*/}
<div className="col-md-6 mb-6">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Expense Category :
</label>
<div className="text-muted">{data?.expenseCategory?.name || "N/A"}</div>
</div>
</div>
{/* Row 3 Amount and Project */}
<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" }}
>
Amount :
</label>
<div className="text-muted">
{data?.amount != null
? `${data?.currency?.symbol ?? "¥"} ${Number(data.amount).toFixed(2)} ${data?.currency?.currencyCode ?? "CN"}`
: "N/A"}
</div>
</div>
</div>
<div className="col-md-6 mb-6">
<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 || "N/A"}</div>
</div>
</div>
{/* Row 4 Created At and Title*/}
<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" }}
>
Created At :
</label>
{/* <div className="text-muted">
{formatUTCToLocalTime(data?.createdAt, true)}
</div> */}
<div className="text-muted">
{data?.createdAt
? formatUTCToLocalTime(data.createdAt, true)
: "N/A"}
</div>
</div>
</div>
<div className="col-md-6 mb-6">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Title :
</label>
<div className="text-muted">{data?.title || "N/A"}</div>
</div>
</div>
{/* Row 5 Payee and Notify*/}
<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" }}
>
Payee :
</label>
<div className="text-muted">{data?.payee || "N/A"}</div>
</div>
</div>
<div className="col-md-6 mb-6">
<div className="d-flex align-items-start">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Notify To :
</label>
<div className="text-muted" style={{ textAlign: "left" }}>
{data?.notifyTo?.length > 0
? data.notifyTo?.map((user, index) => (
<span key={user.id}>
{user.email}
{index < data?.notifyTo?.length - 1 && ", "}
</span>
))
: "N/A"}
</div>
</div>
</div>
{/* Row 6 Strike Date*/}
<div className="col-md-6 mb-6">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Strike Date :
</label>
{/* <div className="text-muted">
{formatUTCToLocalTime(data?.strikeDate)}
</div> */}
<div className="text-muted">
{data?.strikeDate
? formatUTCToLocalTime(data.strikeDate, true)
: "N/A"}
</div>
</div>
</div>
{/* Row 7 Frequency and Buffer Days*/}
<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" }}
>
Frequency :
</label>
<div className="text-muted flex-grow-1 text-start">
{data?.frequency !== undefined
? FREQUENCY_FOR_RECURRING[data.frequency]
: "N/A"}
</div>
</div>
</div>
<div className="col-md-6 mb-6">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Payment Buffer Days :
</label>
<div className="text-muted">{data?.paymentBufferDays || "N/A"}</div>
</div>
</div>
{/* Row 8 Updated At and Number of Iteration*/}
<div className="col-md-6 mb-6">
<div className="d-flex">
<label
className="form-label me-2 mb-0 fw-semibold text-start"
style={{ minWidth: "130px" }}
>
Updated At :
</label>
<div className="text-muted">
{data?.updatedAt
? formatUTCToLocalTime(data.updatedAt, true)
: "N/A"}
</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"
style={{ minWidth: "130px" }}
>
Number of Iteration :
</label>
<div className="text-muted">{data?.numberOfIteration || "N/A"}</div>
</div>
</div>
{/* Row 9 Created By and Updated By*/}
<div className="col-md-6 text-start mb-6">
<div className="d-flex align-items-center">
<label
className="form-label me-2 mb-0 fw-semibold"
style={{ minWidth: "125px" }}
>
Created By :
</label>
<Avatar
size="xs"
classAvatar="m-0 me-1"
firstName={data?.createdBy?.firstName}
lastName={data?.createdBy?.lastName}
/>
<span className="text-muted">
{`${data?.createdBy?.firstName ?? ""} ${data?.createdBy?.lastName ?? ""}`.trim() || "N/A"}
</span>
</div>
</div>
<div className="col-md-6 text-start mb-3">
<div className="d-flex align-items-center">
<label
className="form-label me-2 mb-0 fw-semibold"
style={{ minWidth: "125px" }}
>
Updated By :
</label>
{data?.updatedBy ? (
<>
<Avatar
size="xs"
classAvatar="m-0 me-1"
firstName={data.updatedBy.firstName}
lastName={data.updatedBy.lastName}
/>
<span className="text-muted">
{`${data.updatedBy.firstName ?? ""} ${data.updatedBy.lastName ?? ""}`.trim() || "N/A"}
</span>
</>
) : (
<span className="text-muted">N/A</span>
)}
</div>
</div>
{/* Row 10 Description */}
<div className="col-12 text-start mb-5 d-flex">
<label
className="fw-semibold form-label mb-0"
style={{ minWidth: "140px", whiteSpace: "nowrap" }}
>
Description :
</label>
<div className="text-muted flex-grow-1" style={{ whiteSpace: "pre-wrap" }}>
{data?.description || "N/A"}
</div>
</div>
</div>
{/* </div> */}
</div>
</form>
)
}
export default ViewRecurringExpense

View File

@ -0,0 +1,295 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useController } from "react-hook-form";
import { useDebounce } from "../../utils/appUtils";
import { useEmployeesName } from "../../hooks/useEmployees";
import Avatar from "./Avatar";
const PmsEmployeeInputTag = ({
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 activeIndexRef = useRef(-1);
const debouncedSearch = useDebounce(search, 300);
const { data: employees, isLoading } = useEmployeesName(
projectId,
debouncedSearch,
forAll
);
// Keep both filtered list and cache updated
useEffect(() => {
if (employees?.data?.length) {
setFilteredUsers(employees.data);
activeIndexRef.current = -1;
// cache all fetched users by id
setUserCache((prev) => {
const updated = { ...prev };
employees.data.forEach((u) => {
updated[u.id] = u;
});
return updated;
});
} else {
setFilteredUsers([]);
}
}, [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 (
<div
className="tagify form-control d-flex align-items-center flex-wrap position-relative "
ref={dropdownRef}
>
{/* 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>
);
};
export default PmsEmployeeInputTag;

View File

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

View File

@ -0,0 +1,165 @@
import React, { createContext, useState, useEffect, useContext } from "react";
import Breadcrumb from "../../components/common/Breadcrumb";
import GlobalModel from "../../components/common/GlobalModel";
import { useFab } from "../../Context/FabContext";
import ManageRecurringExpense from "../../components/RecurringExpense/ManageRecurringExpense";
import RecurringExpenseList from "../../components/RecurringExpense/RecurringExpenseList";
import { PAYEE_RECURRING_EXPENSE } from "../../utils/constants";
import { SearchRecurringExpenseSchema } from "../../components/RecurringExpense/RecurringExpenseSchema";
import ViewRecurringExpense from "../../components/RecurringExpense/ViewRecurringExpense";
export const RecurringExpenseContext = createContext();
export const useRecurringExpenseContext = () => {
const context = useContext(RecurringExpenseContext);
if (!context) {
throw new Error("useRecurringExpenseContext must be used within an ExpenseProvider");
}
return context;
};
const RecurringExpensePage = () => {
const [ManageRequest, setManageRequest] = useState({
IsOpen: null,
RecurringId: null,
});
const [viewRecurring, setViewRecurring] = useState({ view: false, recurringId: null })
const [selectedStatuses, setSelectedStatuses] = useState(
PAYEE_RECURRING_EXPENSE.map((s) => s.id)
);
const [search, setSearch] = useState("");
const contextValue = {
setManageRequest,
setViewRecurring
};
const handleStatusChange = (id) => {
setSelectedStatuses((prev) =>
prev.includes(id)
? prev.filter((s) => s !== id)
: [...prev, id]
);
};
return (
<RecurringExpenseContext.Provider value={contextValue}>
<div className="container-fluid">
{/* Breadcrumb */}
<Breadcrumb
data={[
{ label: "Home", link: "/dashboard" },
{ label: "Recurring Expense", link: null },
]}
/>
{/* Top Bar */}
<div className="card my-3 px-sm-4 px-0">
<div className="card-body py-2 px-1">
<div className="row align-items-center mb-0">
{/* Left Column: Search + Filter */}
<div className="col-md-8 col-sm-12 mb-2 mb-md-0">
<div className="d-flex align-items-center flex-wrap gap-0">
<input
type="search"
className="form-control form-control-sm w-25"
placeholder="Search Recurring Expense"
value={search}
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>
{/* Right Column: Add Button */}
<div className="col-md-4 col-sm-12 text-md-end text-start">
<button
className="btn btn-sm btn-primary"
type="button"
onClick={() =>
setManageRequest({
IsOpen: true,
RecurringId: null,
})
}
>
<i className="bx bx-plus-circle me-2"></i>
<span className="d-none d-md-inline-block">
Add Recurring Expense
</span>
</button>
</div>
</div>
</div>
</div>
<RecurringExpenseList filterStatuses={selectedStatuses} search={search} />
{ManageRequest.IsOpen && (
<GlobalModel
isOpen
size="lg"
closeModal={() =>
setManageRequest({ IsOpen: null, expenseId: null })
}
>
<ManageRecurringExpense
key={ManageRequest.RecurringId ?? "new"}
closeModal={() =>
setManageRequest({ IsOpen: null, RecurringId: null })
}
requestToEdit={ManageRequest.RecurringId}
/>
</GlobalModel>
)}
{viewRecurring.view && (
<GlobalModel
isOpen
size="lg"
closeModal={() =>
setViewRecurring({ IsOpen: null, recurringId: null })
}
>
{/* <viewRecurring
key={viewRecurring.RecurringId ?? "new"}
closeModal={() =>
setViewRecurring({ IsOpen: null, recurringId: null })
}
RecurringId={viewRecurring.recurringId}
/> */}
<ViewRecurringExpense RecurringId={viewRecurring.recurringId} />
</GlobalModel>
)}
</div>
</RecurringExpenseContext.Provider>
);
};
export default RecurringExpensePage;

View File

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

View File

@ -57,6 +57,7 @@ import CollectionPage from "../pages/collections/CollectionPage";
import SubscriptionSummary from "../pages/Home/SubscriptionSummary"; import SubscriptionSummary from "../pages/Home/SubscriptionSummary";
import MakeSubscription from "../pages/Home/MakeSubscription"; import MakeSubscription from "../pages/Home/MakeSubscription";
import PaymentRequestPage from "../pages/PaymentRequest/PaymentRequestPage"; import PaymentRequestPage from "../pages/PaymentRequest/PaymentRequestPage";
import RecurringExpensePage from "../pages/RecurringExpense/RecurringExpensePage";
const router = createBrowserRouter( const router = createBrowserRouter(
[ [
{ {
@ -108,6 +109,7 @@ const router = createBrowserRouter(
{ path: "/expenses/:status?/:project?", element: <ExpensePage /> }, { path: "/expenses/:status?/:project?", element: <ExpensePage /> },
{ path: "/expenses", element: <ExpensePage /> }, { path: "/expenses", element: <ExpensePage /> },
{ path: "/payment-request", element: <PaymentRequestPage /> }, { path: "/payment-request", element: <PaymentRequestPage /> },
{ path: "/recurring-payment", element: <RecurringExpensePage /> },
{ path: "/collection", element: <CollectionPage /> }, { path: "/collection", element: <CollectionPage /> },
{ path: "/masters", element: <MasterPage /> }, { path: "/masters", element: <MasterPage /> },
{ path: "/tenants", element: <TenantPage /> }, { path: "/tenants", element: <TenantPage /> },