added recurring payment
This commit is contained in:
parent
40cb641428
commit
5a7c6a29ba
@ -46,6 +46,8 @@
|
||||
<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/spinkit/spinkit.css" />
|
||||
<link rel="stylesheet" href="/assets/vendor/libs/tagify/tagify.css" />
|
||||
<link rel="stylesheet" href="/assets/vendor/libs/tagify/tagify.js" />
|
||||
|
||||
<!-- Helpers -->
|
||||
<script src="/assets/vendor/js/helpers.js"></script>
|
||||
|
||||
879
public/assets/vendor/libs/tagify/tagify.css
vendored
Normal file
879
public/assets/vendor/libs/tagify/tagify.css
vendored
Normal 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;
|
||||
}
|
||||
120
public/assets/vendor/libs/tagify/tagify.js
vendored
Normal file
120
public/assets/vendor/libs/tagify/tagify.js
vendored
Normal file
File diff suppressed because one or more lines are too long
537
src/components/RecurringExpense/ManageRecurringExpense.jsx
Normal file
537
src/components/RecurringExpense/ManageRecurringExpense.jsx
Normal 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;
|
||||
290
src/components/RecurringExpense/RecurringExpenseList.jsx
Normal file
290
src/components/RecurringExpense/RecurringExpenseList.jsx
Normal 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;
|
||||
112
src/components/RecurringExpense/RecurringExpenseSchema.js
Normal file
112
src/components/RecurringExpense/RecurringExpenseSchema.js
Normal 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(),
|
||||
});
|
||||
|
||||
|
||||
|
||||
342
src/components/RecurringExpense/RecurringRexpenseList.jsx
Normal file
342
src/components/RecurringExpense/RecurringRexpenseList.jsx
Normal 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;
|
||||
307
src/components/RecurringExpense/ViewRecurringExpense.jsx
Normal file
307
src/components/RecurringExpense/ViewRecurringExpense.jsx
Normal 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
|
||||
295
src/components/common/PmsEmployeeInputTag.jsx
Normal file
295
src/components/common/PmsEmployeeInputTag.jsx
Normal 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;
|
||||
@ -10,6 +10,16 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import showToast from "../../services/toastService";
|
||||
|
||||
|
||||
export const useRecurringStatus = ()=>{
|
||||
return useQuery({
|
||||
queryKey:["RecurringStatus"],
|
||||
queryFn:async()=>{
|
||||
const resp = await MasterRespository.getRecurringStatus();
|
||||
return resp.data
|
||||
}
|
||||
})
|
||||
}
|
||||
export const useCurrencies = () => {
|
||||
return useQuery({
|
||||
queryKey: ["currencies"],
|
||||
|
||||
165
src/pages/RecurringExpense/RecurringExpensePage.jsx
Normal file
165
src/pages/RecurringExpense/RecurringExpensePage.jsx
Normal 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;
|
||||
@ -148,4 +148,6 @@ export const MasterRespository = {
|
||||
api.put(`/api/Master/payment-adjustment-head/edit/${id}`, data),
|
||||
|
||||
getCurrencies: () => api.get(`/api/Master/currencies/list`),
|
||||
|
||||
getRecurringStatus:()=>api.get(`/api/Master/recurring-status/list`)
|
||||
};
|
||||
|
||||
@ -57,6 +57,7 @@ import CollectionPage from "../pages/collections/CollectionPage";
|
||||
import SubscriptionSummary from "../pages/Home/SubscriptionSummary";
|
||||
import MakeSubscription from "../pages/Home/MakeSubscription";
|
||||
import PaymentRequestPage from "../pages/PaymentRequest/PaymentRequestPage";
|
||||
import RecurringExpensePage from "../pages/RecurringExpense/RecurringExpensePage";
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
@ -108,6 +109,7 @@ const router = createBrowserRouter(
|
||||
{ path: "/expenses/:status?/:project?", element: <ExpensePage /> },
|
||||
{ path: "/expenses", element: <ExpensePage /> },
|
||||
{ path: "/payment-request", element: <PaymentRequestPage /> },
|
||||
{ path: "/recurring-payment", element: <RecurringExpensePage /> },
|
||||
{ path: "/collection", element: <CollectionPage /> },
|
||||
{ path: "/masters", element: <MasterPage /> },
|
||||
{ path: "/tenants", element: <TenantPage /> },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user