diff --git a/src/components/ActionsMenu/index.jsx b/src/components/ActionsMenu/index.jsx index ab08abe..4eb6940 100644 --- a/src/components/ActionsMenu/index.jsx +++ b/src/components/ActionsMenu/index.jsx @@ -3,6 +3,7 @@ import PT from "prop-types"; import cn from "classnames"; import { usePopper } from "react-popper"; import Button from "components/Button"; +import Tooltip from "components/Tooltip"; import IconArrowDown from "../../assets/images/icon-arrow-down-narrow.svg"; import { useClickOutside } from "utils/hooks"; import { negate, stopPropagation } from "utils/misc"; @@ -39,48 +40,6 @@ const ActionsMenu = ({ setIsOpen(negate); }, []); - const onItemClick = useCallback( - (event) => { - let index = +event.target.dataset.actionIndex; - let item = items[index]; - if (!item || item.disabled || item.separator) { - return; - } - closeMenu(); - item.action?.(); - }, - [items, closeMenu] - ); - - const menuItems = useMemo( - () => - items.map((item, index) => { - if (item.hidden) { - return null; - } else if (item.separator) { - return <div key={index} className={compStyles.separator} />; - } else { - return ( - <div - key={index} - data-action-index={index} - onClick={onItemClick} - role="button" - tabIndex={0} - className={cn( - compStyles.item, - { [compStyles.itemDisabled]: item.disabled }, - item.className - )} - > - {item.label} - </div> - ); - } - }), - [items, onItemClick] - ); - return ( <div className={compStyles.container} @@ -104,8 +63,8 @@ const ActionsMenu = ({ </Button> {isOpen && ( <Menu - items={menuItems} - onClickOutside={closeMenu} + close={closeMenu} + items={items} referenceElement={referenceElement} strategy={popupStrategy} /> @@ -123,6 +82,7 @@ ActionsMenu.propTypes = { label: PT.string, action: PT.func, separator: PT.bool, + disabled: PT.bool, hidden: PT.bool, }) ), @@ -138,7 +98,7 @@ export default ActionsMenu; * @param {Object} props component properties * @returns {JSX.Element} */ -const Menu = ({ items, onClickOutside, referenceElement, strategy }) => { +const Menu = ({ close, items, referenceElement, strategy }) => { const [popperElement, setPopperElement] = useState(null); const [arrowElement, setArrowElement] = useState(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { @@ -180,7 +140,75 @@ const Menu = ({ items, onClickOutside, referenceElement, strategy }) => { ], }); - useClickOutside(popperElement, onClickOutside, []); + const onClickItem = useCallback( + (event) => { + let targetData = event.target.dataset; + let index = +targetData.actionIndex; + let item = items[index]; + if (!item || targetData.disabled || item.separator) { + return; + } + close(); + item.action?.(); + }, + [close, items] + ); + + useClickOutside(popperElement, close, []); + + const menuItems = useMemo(() => { + return items.map((item, index) => { + if (item.hidden) { + return null; + } else if (item.separator) { + return <div key={index} className={compStyles.separator} />; + } else { + let disabled = !!item.disabled; + let reasonsDisabled = Array.isArray(item.disabled) + ? item.disabled + : null; + let attrs = { + key: index, + "data-action-index": index, + onClick: onClickItem, + role: "button", + tabIndex: 0, + className: cn( + compStyles.item, + { [compStyles.itemDisabled]: disabled }, + item.className + ), + }; + if (disabled) { + attrs["data-disabled"] = true; + } + return ( + <div {...attrs}> + {reasonsDisabled ? ( + <Tooltip + content={ + reasonsDisabled.length === 1 ? ( + reasonsDisabled[0] + ) : ( + <ul> + {reasonsDisabled.map((text, index) => ( + <li key={index}>{text}</li> + ))} + </ul> + ) + } + strategy="fixed" + > + {item.label} + </Tooltip> + ) : ( + item.label + )} + </div> + ); + } + }); + }, [items, onClickItem]); return ( <div @@ -189,7 +217,7 @@ const Menu = ({ items, onClickOutside, referenceElement, strategy }) => { style={styles.popper} {...attributes.popper} > - <div className={compStyles.items}>{items}</div> + <div className={compStyles.items}>{menuItems}</div> <div ref={setArrowElement} style={styles.arrow} @@ -200,8 +228,17 @@ const Menu = ({ items, onClickOutside, referenceElement, strategy }) => { }; Menu.propTypes = { - items: PT.array.isRequired, - onClickOutside: PT.func.isRequired, + close: PT.func.isRequired, + items: PT.arrayOf( + PT.shape({ + label: PT.string, + action: PT.func, + checkDisabled: PT.func, + disabled: PT.bool, + separator: PT.bool, + hidden: PT.bool, + }) + ), referenceElement: PT.object, strategy: PT.oneOf(["absolute", "fixed"]), }; diff --git a/src/components/ActionsMenu/styles.module.scss b/src/components/ActionsMenu/styles.module.scss index 0346f39..e780ce5 100644 --- a/src/components/ActionsMenu/styles.module.scss +++ b/src/components/ActionsMenu/styles.module.scss @@ -71,9 +71,8 @@ } .itemDisabled { - color: gray; - opacity: 0.6; - pointer-events: none; + color: #bbb; + cursor: default; } .hidden { diff --git a/src/components/CurrencyField/index.jsx b/src/components/CurrencyField/index.jsx new file mode 100644 index 0000000..eea33c9 --- /dev/null +++ b/src/components/CurrencyField/index.jsx @@ -0,0 +1,63 @@ +import React, { useCallback } from "react"; +import PT from "prop-types"; +import TextField from "components/TextField"; + +/** + * Displays text field with optional label. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const CurrencyField = (props) => { + const { onChange } = props; + + const onChangeValue = useCallback( + (value) => { + onChange(normalizeValue(value)); + }, + [onChange] + ); + + return <TextField {...props} onChange={onChangeValue} />; +}; + +CurrencyField.propTypes = { + className: PT.string, + error: PT.string, + id: PT.string, + isDisabled: PT.bool, + isTouched: PT.bool, + label: PT.string, + name: PT.string.isRequired, + onBlur: PT.func, + onChange: PT.func, + onFocus: PT.func, + size: PT.oneOf(["small", "medium"]), + value: PT.oneOfType([PT.number, PT.string]).isRequired, +}; + +export default CurrencyField; + +/** + * Returns normalized payment amount. + * + * @param {string} value peyment amount + * @returns {string} + */ +function normalizeValue(value) { + if (!value) { + return value; + } + value = value.trim(); + let dotIndex = value.lastIndexOf("."); + if (isNaN(+value) || dotIndex < 0) { + return value; + } + if (dotIndex === 0) { + return "0" + value; + } + if (value.length - dotIndex > 3) { + return value.slice(0, dotIndex + 3); + } + return value; +} diff --git a/src/components/CurrencyField/styles.module.scss b/src/components/CurrencyField/styles.module.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/components/Page/index.jsx b/src/components/Page/index.jsx index a9b51b0..bfedb09 100644 --- a/src/components/Page/index.jsx +++ b/src/components/Page/index.jsx @@ -15,7 +15,6 @@ import styles from "./styles.module.scss"; */ const Page = ({ className, children }) => ( <div className={cn(styles.container, className)}> - {children} <ReduxToastr timeOut={TOAST_DEFAULT_TIMEOUT} position="top-right" @@ -26,6 +25,7 @@ const Page = ({ className, children }) => ( transitionIn="fadeIn" transitionOut="fadeOut" /> + {children} </div> ); diff --git a/src/components/Page/styles.module.scss b/src/components/Page/styles.module.scss index 49417bf..5616900 100644 --- a/src/components/Page/styles.module.scss +++ b/src/components/Page/styles.module.scss @@ -14,6 +14,7 @@ @include desktop { flex-direction: row; + flex-wrap: wrap; } *, diff --git a/src/components/TextField/index.jsx b/src/components/TextField/index.jsx index 4f968b3..73f0b4c 100644 --- a/src/components/TextField/index.jsx +++ b/src/components/TextField/index.jsx @@ -1,6 +1,7 @@ import React, { useCallback } from "react"; import PT from "prop-types"; import cn from "classnames"; +import ValidationError from "components/ValidationError"; import styles from "./styles.module.scss"; /** @@ -11,9 +12,12 @@ import styles from "./styles.module.scss"; */ const TextField = ({ className, + error, + errorClassName, id, + inputRef, isDisabled = false, - isValid = true, + isTouched = false, label, name, onBlur, @@ -39,7 +43,7 @@ const TextField = ({ { [styles.hasLabel]: !!label, [styles.disabled]: isDisabled, - [styles.invalid]: !isValid, + [styles.invalid]: !!error, }, className )} @@ -50,21 +54,30 @@ const TextField = ({ disabled={isDisabled} id={id} name={name} - type="text" - value={value} onBlur={onBlur} onChange={onInputChange} onFocus={onFocus} + ref={inputRef} + type="text" + value={value} /> + {isTouched && error && ( + <ValidationError className={cn(styles.error, errorClassName)}> + {error} + </ValidationError> + )} </div> ); }; TextField.propTypes = { className: PT.string, + error: PT.string, + errorClassName: PT.string, id: PT.string, + inputRef: PT.oneOfType([PT.object, PT.func]), isDisabled: PT.bool, - isValid: PT.bool, + isTouched: PT.bool, label: PT.string, name: PT.string.isRequired, onBlur: PT.func, diff --git a/src/components/TextField/styles.module.scss b/src/components/TextField/styles.module.scss index f36534c..107f6e2 100644 --- a/src/components/TextField/styles.module.scss +++ b/src/components/TextField/styles.module.scss @@ -38,8 +38,8 @@ } &.invalid { - input, - input:hover { + input.input, + input.input:hover { border-color: $error-color; color: $error-text-color; } diff --git a/src/components/Tooltip/styles.module.scss b/src/components/Tooltip/styles.module.scss index c0d1d6e..4b0a779 100644 --- a/src/components/Tooltip/styles.module.scss +++ b/src/components/Tooltip/styles.module.scss @@ -1,3 +1,6 @@ +@import "styles/variables"; +@import "styles/mixins"; + .container { position: relative; display: inline-flex; @@ -14,8 +17,14 @@ border-radius: 8px; padding: 10px 15px; line-height: 22px; - box-shadow: 0px 5px 25px #c6c6c6; + font-size: $font-size-px; + @include roboto-regular; + font-weight: normal; + letter-spacing: normal; + text-transform: none; + color: $text-color; background: #fff; + box-shadow: 0px 5px 25px #c6c6c6; ul { margin: 0; diff --git a/src/components/ValidationError/styles.module.scss b/src/components/ValidationError/styles.module.scss index da60411..38da73e 100644 --- a/src/components/ValidationError/styles.module.scss +++ b/src/components/ValidationError/styles.module.scss @@ -1,5 +1,5 @@ .container { - margin: 10px 0; + margin: 10px 0 0; border: 1px solid #ffd5d1; padding: 9px 10px; min-height: 40px; diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 6c28487..7d0078b 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -4,6 +4,7 @@ import * as ALERT from "./workPeriods/alerts"; import * as API_CHALLENGE_PAYMENT_STATUS from "./workPeriods/apiChallengePaymentStatus"; import * as API_PAYMENT_STATUS from "./workPeriods/apiPaymentStatus"; import * as API_SORT_BY from "./workPeriods/apiSortBy"; +import * as ERROR_MESSAGE from "./workPeriods/errorMessage"; import * as SORT_BY from "./workPeriods/sortBy"; import * as SORT_ORDER from "./workPeriods/sortOrder"; import * as PAYMENT_STATUS from "./workPeriods/paymentStatus"; @@ -14,6 +15,7 @@ export { API_CHALLENGE_PAYMENT_STATUS, API_PAYMENT_STATUS, API_SORT_BY, + ERROR_MESSAGE, SORT_BY, SORT_ORDER, PAYMENT_STATUS, diff --git a/src/constants/workPeriods/errorMessage.js b/src/constants/workPeriods/errorMessage.js new file mode 100644 index 0000000..e14e1b0 --- /dev/null +++ b/src/constants/workPeriods/errorMessage.js @@ -0,0 +1,2 @@ +export const AMOUNT_OUT_OF_BOUNDS = + "Amount should be greater than 0 and less than 100,000."; diff --git a/src/routes/WorkPeriods/components/PaymentActions/index.jsx b/src/routes/WorkPeriods/components/PaymentActions/index.jsx index 38742f0..de2a419 100644 --- a/src/routes/WorkPeriods/components/PaymentActions/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentActions/index.jsx @@ -34,7 +34,10 @@ const PaymentActions = ({ className, daysPaid, daysWorked, payment }) => { action() { setIsOpenEditModal(true); }, - disabled: paymentStatus === PAYMENT_STATUS.IN_PROGRESS, + disabled: + paymentStatus === PAYMENT_STATUS.IN_PROGRESS || + paymentStatus === PAYMENT_STATUS.FAILED || + paymentStatus === PAYMENT_STATUS.CANCELLED, }, { label: "Cancel Payment", diff --git a/src/routes/WorkPeriods/components/PaymentModalAdditional/index.jsx b/src/routes/WorkPeriods/components/PaymentModalAdditional/index.jsx index 0838110..1920340 100644 --- a/src/routes/WorkPeriods/components/PaymentModalAdditional/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentModalAdditional/index.jsx @@ -1,15 +1,15 @@ import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; import PT from "prop-types"; import moment from "moment"; import debounce from "lodash/debounce"; import Modal from "components/Modal"; import Spinner from "components/Spinner"; -import TextField from "components/TextField"; -import ValidationError from "components/ValidationError"; -import { makeToast } from "components/ToastrMessage"; -import { postWorkPeriodPayment } from "services/workPeriods"; +import CurrencyField from "components/CurrencyField"; +import { addWorkPeriodPayment } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; import { preventDefault, validateAmount } from "utils/misc"; +import { ERROR_MESSAGE } from "constants/workPeriods"; import styles from "./styles.module.scss"; /** @@ -21,9 +21,10 @@ import styles from "./styles.module.scss"; */ const PaymentModalAdditional = ({ period, removeModal }) => { const [isModalOpen, setIsModalOpen] = useState(true); - const [amount, setAmount] = useState("0"); + const [amount, setAmount] = useState(""); const [isAmountValid, setIsAmountValid] = useState(true); const [isProcessing, setIsProcessing] = useState(false); + const dispatch = useDispatch(); const onApprove = () => { let isAmountValid = validateAmount(amount); @@ -37,10 +38,6 @@ const PaymentModalAdditional = ({ period, removeModal }) => { setIsModalOpen(false); }, []); - const onChangeAmount = useCallback((amount) => { - setAmount((amount || "").trim()); - }, []); - const validateAmountDebounced = useCallback( debounce( (amount) => { @@ -61,23 +58,19 @@ const PaymentModalAdditional = ({ period, removeModal }) => { if (!isProcessing) { return; } - postWorkPeriodPayment({ workPeriodId: period.id, days: 0, amount }) - .then(() => { - makeToast("Additional payment scheduled for resource", "success"); - setIsModalOpen(false); - }) - .catch((error) => { - makeToast(error.toString()); - }) - .finally(() => { - setIsProcessing(false); - }); - }, [amount, isProcessing, period.id]); + (async function () { + let ok = await dispatch( + addWorkPeriodPayment(period.id, { days: 0, amount }) + ); + setIsModalOpen(!ok); + setIsProcessing(false); + })(); + }, [amount, isProcessing, period.id, dispatch]); return ( <Modal approveColor="primary" - approveDisabled={!isAmountValid || isProcessing} + approveDisabled={!amount || !isAmountValid || isProcessing} approveText="Process Payment" dismissText="Cancel" title="Additional Payment" @@ -94,23 +87,19 @@ const PaymentModalAdditional = ({ period, removeModal }) => { <> <div className={styles.description}> Additional payment for Resource Booking "{period.userHandle} - " for week "{moment(period.start).format("MM/DD")} + " for the week "{moment(period.start).format("MM/DD")} - {moment(period.end).format("MM/DD")}" </div> <form className={styles.form} action="#" onSubmit={preventDefault}> - <TextField + <CurrencyField className={styles.amountField} - isValid={isAmountValid} + error={isAmountValid ? null : ERROR_MESSAGE.AMOUNT_OUT_OF_BOUNDS} + isTouched={true} label="Amount ($)" name={`payment_amount_${period.id}`} + onChange={setAmount} value={amount} - onChange={onChangeAmount} /> - {!isAmountValid && ( - <ValidationError className={styles.amountError}> - Amount should be greater than 0 and less than 100,000. - </ValidationError> - )} </form> </> )} diff --git a/src/routes/WorkPeriods/components/PaymentModalEdit/index.jsx b/src/routes/WorkPeriods/components/PaymentModalEdit/index.jsx index 6c93731..f2a2c4b 100644 --- a/src/routes/WorkPeriods/components/PaymentModalEdit/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentModalEdit/index.jsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; import PT from "prop-types"; import IntegerFieldHinted from "components/IntegerFieldHinted"; import Modal from "components/Modal"; import Spinner from "components/Spinner"; import TextField from "components/TextField"; -import { makeToast } from "components/ToastrMessage"; -import { patchWorkPeriodPayment } from "services/workPeriods"; +import { updateWorkPeriodPayment } from "store/thunks/workPeriods"; import { currencyFormatter } from "utils/formatters"; import { preventDefault } from "utils/misc"; import { CHALLENGE_PAYMENT_ACTIVE_STATUSES } from "constants/workPeriods"; @@ -22,6 +22,10 @@ const PaymentModalEdit = ({ daysPaid, daysWorked, payment, removeModal }) => { const [isModalOpen, setIsModalOpen] = useState(true); const [isProcessing, setIsProcessing] = useState(false); const [days, setDays] = useState(payment.days); + const dispatch = useDispatch(); + + const isChanged = days !== payment.days; + const { id: paymentId, workPeriodId: periodId } = payment; const maxDays = daysWorked - @@ -41,27 +45,27 @@ const PaymentModalEdit = ({ daysPaid, daysWorked, payment, removeModal }) => { setIsModalOpen(false); }, []); + const onChangeDays = useCallback((days) => { + setDays(days); + }, []); + useEffect(() => { if (!isProcessing) { return; } - patchWorkPeriodPayment(payment.id, { amount, days }) - .then(() => { - makeToast("Payment was successfully updated", "success"); - setIsModalOpen(false); - }) - .catch((error) => { - makeToast(error.toString()); - }) - .finally(() => { - setIsProcessing(false); - }); - }, [amount, days, isProcessing, payment.id]); + (async function () { + let ok = await dispatch( + updateWorkPeriodPayment(periodId, paymentId, { amount, days }) + ); + setIsModalOpen(!ok); + setIsProcessing(false); + })(); + }, [amount, days, isProcessing, paymentId, periodId, dispatch]); return ( <Modal approveColor="primary" - approveDisabled={isProcessing} + approveDisabled={!isChanged || isProcessing} approveText="Update" title="Edit Payment" controls={isProcessing ? null : undefined} @@ -102,7 +106,7 @@ const PaymentModalEdit = ({ daysPaid, daysWorked, payment, removeModal }) => { <td> <IntegerFieldHinted name="day_cnt" - onChange={setDays} + onChange={onChangeDays} maxValue={maxDays} minValue={1} maxValueMessage="Cannot pay for more days than the user has worked for" @@ -131,6 +135,7 @@ PaymentModalEdit.propTypes = { id: PT.string.isRequired, memberRate: PT.number.isRequired, status: PT.string.isRequired, + workPeriodId: PT.string.isRequired, }), removeModal: PT.func.isRequired, }; diff --git a/src/routes/WorkPeriods/components/PaymentModalEditAdditional/index.jsx b/src/routes/WorkPeriods/components/PaymentModalEditAdditional/index.jsx index dca47d6..761fcc8 100644 --- a/src/routes/WorkPeriods/components/PaymentModalEditAdditional/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentModalEditAdditional/index.jsx @@ -1,14 +1,14 @@ import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; import PT from "prop-types"; import debounce from "lodash/debounce"; import Modal from "components/Modal"; import Spinner from "components/Spinner"; -import TextField from "components/TextField"; -import ValidationError from "components/ValidationError"; -import { makeToast } from "components/ToastrMessage"; -import { patchWorkPeriodPayment } from "services/workPeriods"; +import CurrencyField from "components/CurrencyField"; +import { updateWorkPeriodPayment } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; import { preventDefault, validateAmount } from "utils/misc"; +import { ERROR_MESSAGE } from "constants/workPeriods"; import styles from "./styles.module.scss"; /** @@ -18,12 +18,16 @@ import styles from "./styles.module.scss"; * @returns {JSX.Element} */ const PaymentModalEditAdditional = ({ payment, removeModal }) => { + const paymentAmount = payment.amount + ""; const [isModalOpen, setIsModalOpen] = useState(true); - const [amount, setAmount] = useState(payment.amount); + const [amount, setAmount] = useState(paymentAmount); const [isAmountValid, setIsAmountValid] = useState(true); const [isProcessing, setIsProcessing] = useState(false); + const dispatch = useDispatch(); - const amountControlId = `edit_pmt_amt_${payment.id}`; + const isChanged = amount !== paymentAmount; + const { id: paymentId, workPeriodId: periodId } = payment; + const amountControlId = `edit_pmt_amt_${paymentId}`; const onApprove = () => { let isAmountValid = validateAmount(amount); @@ -37,8 +41,8 @@ const PaymentModalEditAdditional = ({ payment, removeModal }) => { setIsModalOpen(false); }, []); - const onAmountChange = useCallback((amount) => { - setAmount((amount || "").trim()); + const onChangeAmount = useCallback((amount) => { + setAmount(amount); }, []); const validateAmountDebounced = useCallback( @@ -61,26 +65,23 @@ const PaymentModalEditAdditional = ({ payment, removeModal }) => { if (!isProcessing) { return; } - patchWorkPeriodPayment(payment.id, { amount }) - .then(() => { - makeToast("Payment was successfully updated", "success"); - setIsModalOpen(false); - }) - .catch((error) => { - makeToast(error.toString()); - }) - .finally(() => { - setIsProcessing(false); - }); - }, [amount, isProcessing, payment.id]); + (async function () { + let ok = await dispatch( + updateWorkPeriodPayment(periodId, paymentId, { amount }) + ); + setIsModalOpen(!ok); + setIsProcessing(false); + })(); + }, [amount, isProcessing, paymentId, periodId, dispatch]); return ( <Modal approveColor="primary" - approveDisabled={!isAmountValid || isProcessing} + approveDisabled={!isChanged || !isAmountValid || isProcessing} approveText="Update" title="Edit Additional Payment" controls={isProcessing ? null : undefined} + isDisabled={isProcessing} isOpen={isModalOpen} onApprove={onApprove} onClose={removeModal} @@ -96,18 +97,16 @@ const PaymentModalEditAdditional = ({ payment, removeModal }) => { <label htmlFor={amountControlId}>Amount:</label> </div> <div className={styles.fieldControl}> - <TextField + <CurrencyField + error={ + isAmountValid ? null : ERROR_MESSAGE.AMOUNT_OUT_OF_BOUNDS + } id={amountControlId} - isValid={isAmountValid} + isTouched={isChanged} name="edit_payment_amount" - onChange={onAmountChange} + onChange={onChangeAmount} value={amount} /> - {!isAmountValid && ( - <ValidationError> - Amount should be greater than 0 and less than 100,000. - </ValidationError> - )} </div> </div> </form> @@ -124,6 +123,7 @@ PaymentModalEditAdditional.propTypes = { payment: PT.shape({ amount: PT.number.isRequired, id: PT.string.isRequired, + workPeriodId: PT.string.isRequired, }).isRequired, removeModal: PT.func.isRequired, }; diff --git a/src/routes/WorkPeriods/components/PeriodActions/index.jsx b/src/routes/WorkPeriods/components/PeriodActions/index.jsx index b531657..9d3ba73 100644 --- a/src/routes/WorkPeriods/components/PeriodActions/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodActions/index.jsx @@ -1,9 +1,14 @@ import React, { useCallback, useMemo, useState } from "react"; import PT from "prop-types"; import cn from "classnames"; +import moment from "moment"; import ActionsMenu from "components/ActionsMenu"; import PaymentModalAdditional from "../PaymentModalAdditional"; import PaymentModalUpdateBA from "../PaymentModalUpdateBA"; +import { + REASON_DISABLED, + REASON_DISABLED_MESSAGE_MAP, +} from "constants/workPeriods"; import styles from "./styles.module.scss"; /** @@ -32,6 +37,7 @@ const PeriodActions = ({ className, period, periodData }) => { action() { setIsOpenAddPaymentModal(true); }, + disabled: checkDisabled(period), }, ]; if (payments?.length) { @@ -40,10 +46,11 @@ const PeriodActions = ({ className, period, periodData }) => { action() { setIsOpenUpdateBAModal(true); }, + disabled: false, }); } return actions; - }, [payments]); + }, [period, payments]); return ( <div className={cn(styles.container, className)}> @@ -73,6 +80,7 @@ PeriodActions.propTypes = { className: PT.string, period: PT.shape({ id: PT.string.isRequired, + billingAccountId: PT.number, start: PT.oneOfType([PT.number, PT.string]).isRequired, end: PT.oneOfType([PT.number, PT.string]).isRequired, }).isRequired, @@ -82,3 +90,18 @@ PeriodActions.propTypes = { }; export default PeriodActions; + +function checkDisabled(period) { + let reasonsDisabled = []; + if (moment(period.start).isAfter(Date.now())) { + reasonsDisabled.push( + REASON_DISABLED_MESSAGE_MAP[REASON_DISABLED.NOT_ALLOW_FUTURE_WEEK] + ); + } + if (!period.billingAccountId) { + reasonsDisabled.push( + REASON_DISABLED_MESSAGE_MAP[REASON_DISABLED.NO_BILLING_ACCOUNT] + ); + } + return reasonsDisabled.length ? reasonsDisabled : false; +} diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 13da1d7..b4312e4 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -133,12 +133,8 @@ const actionHandlers = { const dateRange = state.filters.dateRange; const periodStart = dateRange[0]; const periodEnd = dateRange[1]; - const periodStartValue = periodStart.valueOf(); - const periodEndValue = periodEnd.valueOf(); for (let period of periods) { periodsById[period.id] = true; - period.start = periodStartValue; - period.end = periodEndValue; let periodData = initPeriodData(period); let daysWorkedMax = computeDaysWorkedMax( period.bookingStart, diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index fc5f51d..4f848d2 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -36,6 +36,53 @@ import { import { RESOURCE_BOOKING_STATUS, WORK_PERIODS_PATH } from "constants/index.js"; import { currencyFormatter } from "utils/formatters"; +/** + * A thunk that adds working period's payment and reloads working period data + * after some delay. + * + * @param {string} workPeriodId working period id + * @param {Object} data payment data + * @param {string|number} data.amount payment amount + * @param {number} data.days number of days for payment + * @param {string} [data.workPeriodId] working period id + * @param {number} [periodUpdateDelay] update delay for period data + * @returns {function} + */ +export const addWorkPeriodPayment = + (workPeriodId, data, periodUpdateDelay = SERVER_DATA_UPDATE_DELAY) => + async (dispatch) => { + let errorMessage = null; + try { + let paymentData = await services.postWorkPeriodPayment({ + ...data, + workPeriodId, + }); + if ("error" in paymentData) { + errorMessage = paymentData.error.message; + } + } catch (error) { + errorMessage = error.toString(); + } + if (errorMessage) { + makeToast(errorMessage); + return false; + } + [, errorMessage] = await dispatch( + loadWorkPeriodData(workPeriodId, periodUpdateDelay) + ); + if (errorMessage) { + makeToast( + "Additional payment scheduled for resource " + + "but working period data was not reloaded.\n" + + errorMessage, + "warning" + ); + } else { + makeToast("Additional payment scheduled for resource", "success"); + } + return true; + }; + /** * A thunk that cancels specific working period payment, reloads WP data * and updates store's state after certain delay. @@ -66,7 +113,12 @@ export const cancelWorkPeriodPayment = loadWorkPeriodData(periodId, periodUpdateDelay) ); if (errorMessage) { - makeToast("Failed to reload working period data. " + errorMessage); + makeToast( + `Payment ${paymentData.amount} was marked as "cancelled" ` + + "but working period data wos not reloaded.\n" + + errorMessage, + "warning" + ); } else if (periodData) { let userHandle = periodData.userHandle; let amount = null; @@ -289,6 +341,50 @@ export const toggleWorkPeriodDetails = } }; +/** + * A thunk that updates working period's payment and reloads working period data + * after some delay. + * + * @param {string} periodId working period id + * @param {string} paymentId working period payment id + * @param {Object} data payment data + * @param {string|number} data.amount payment amount + * @param {number} [data.days] number of days for payment + * @param {number} [periodUpdateDelay] update delay for period data + * @returns {function} + */ +export const updateWorkPeriodPayment = + (periodId, paymentId, data, periodUpdateDelay = SERVER_DATA_UPDATE_DELAY) => + async (dispatch) => { + let paymentData = null; + let errorMessage = null; + try { + paymentData = await services.patchWorkPeriodPayment(paymentId, data); + paymentData = normalizePaymentData(paymentData); + } catch (error) { + errorMessage = error.toString(); + } + if (errorMessage) { + makeToast(errorMessage); + return false; + } + dispatch(actions.setWorkPeriodPaymentData(paymentData)); + [, errorMessage] = await dispatch( + loadWorkPeriodData(periodId, periodUpdateDelay) + ); + if (errorMessage) { + makeToast( + "Payment was successfully updated " + + "but working period data was not reloaded.\n" + + errorMessage, + "warning" + ); + } else { + makeToast("Payment was successfully updated", "success"); + } + return true; + }; + /** * A thunk that updates the billing accounts for all the payments from the * specific working period. diff --git a/src/styles/toastr.scss b/src/styles/toastr.scss index c707bbb..3589b5c 100644 --- a/src/styles/toastr.scss +++ b/src/styles/toastr.scss @@ -2,44 +2,45 @@ @import "variables"; .redux-toastr { - position: absolute; + flex: 0 0 auto; + z-index: 10000; + position: sticky; left: 0; - top: 0; + top: -4px; right: 0; margin: 0; border: none; padding: 0; - height: auto; + width: 100%; + height: 0; background: transparent; + overflow: visible; @include desktop { - left: $sidebar-width; + padding-left: $sidebar-width; } > div { - position: absolute; - left: 10px; - top: 24px; - right: 10px; + position: relative; margin: 0; border: none; padding: 0; height: auto; background: transparent; + } + + .top-right { + z-index: 1000; + position: absolute; + left: 10px; + top: 24px; + right: 10px; @include desktop { left: 22px; top: 24px; right: 14px; } - } - - .top-right { - z-index: 1000; - position: absolute; - left: 0; - top: 0; - right: 0; > div { margin-top: 10px; diff --git a/src/utils/hooks.js b/src/utils/hooks.js index bf25bac..0ccfc4c 100644 --- a/src/utils/hooks.js +++ b/src/utils/hooks.js @@ -50,3 +50,19 @@ export const useUpdateEffect = (effect, deps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); }; + +/** + * A hook that returns previously saved value before component updated. + * + * @param {*} value value to save + * @returns {*} + */ +export const usePrevious = (value) => { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }); + + return ref.current; +}; diff --git a/src/utils/misc.js b/src/utils/misc.js index cc49429..d421899 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -243,13 +243,5 @@ export const hoursToHumanReadableTime = (timeHrs) => { */ export function validateAmount(value) { let amount = +value; - let valueStr = value + ""; - return ( - !isNaN(amount) && - (amount.toFixed(0) === valueStr || - amount.toFixed(1) === valueStr || - amount.toFixed(2) === valueStr) && - amount > 0 && - amount < 1e5 - ); + return !isNaN(amount) && amount > 0 && amount < 1e5 && !value.endsWith("."); } diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 7743cbd..bc1e3dc 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -168,6 +168,8 @@ export function normalizePeriodItems(items) { bookingEnd: item.endDate ? moment(item.endDate).format(DATE_FORMAT_ISO) : "", + start: workPeriod.startDate, + end: workPeriod.endDate, weeklyRate: item.memberRate, data: normalizePeriodData(workPeriod), });