diff --git a/src/assets/images/icon-cross-light.svg b/src/assets/images/icon-cross-light.svg new file mode 100644 index 0000000..add82d0 --- /dev/null +++ b/src/assets/images/icon-cross-light.svg @@ -0,0 +1,30 @@ + + + + + diff --git a/src/components/Button/index.jsx b/src/components/Button/index.jsx index a9ca78b..509cf17 100644 --- a/src/components/Button/index.jsx +++ b/src/components/Button/index.jsx @@ -9,11 +9,12 @@ import styles from "./styles.module.scss"; * @param {Object} props component properties * @param {Object} props.children button text * @param {string} [props.className] class name added to root element - * @param {'primary'|'primary-dark'|'primary-light'} [props.color] button color + * @param {'primary'|'primary-dark'|'primary-light'|'error'|'warning'} [props.color] + * button color * @param {boolean} [props.isDisabled] if button is disabled * @param {boolean} [props.isSelected] if button is selected * @param {string} [props.name] button name - * @param {(e: any) => void} props.onClick function called when button is clicked + * @param {(e: any) => void} [props.onClick] function called when button is clicked * @param {'medium'|'small'} [props.size] button size * @param {'circle'|'rounded'} [props.style] button style * @param {'button'|'submit'|'reset'} [props.type] button type @@ -42,13 +43,11 @@ const Button = ({ type={type} className={cn( styles.button, - { - [styles.selected]: isSelected, - [styles[color]]: true, - [styles[size]]: true, - [styles[style]]: true, - [styles[variant]]: true, - }, + styles[color], + styles[size], + styles[style], + styles[variant], + { [styles.selected]: isSelected }, className )} onClick={onClick} @@ -60,7 +59,13 @@ const Button = ({ Button.propTypes = { children: PT.node, className: PT.string, - color: PT.oneOf(["primary"]), + color: PT.oneOf([ + "primary", + "primary-dark", + "primary-light", + "error", + "warning", + ]), isDisabled: PT.bool, isSelected: PT.bool, name: PT.string, diff --git a/src/components/Button/styles.module.scss b/src/components/Button/styles.module.scss index 2ae6cd1..1cfbe34 100644 --- a/src/components/Button/styles.module.scss +++ b/src/components/Button/styles.module.scss @@ -7,6 +7,7 @@ align-items: center; @include roboto-bold; letter-spacing: 0.8px; + white-space: nowrap; text-transform: uppercase; outline: none; cursor: pointer; @@ -61,6 +62,16 @@ color: $primary-dark-text-color; } + &.error { + border-color: $error-color; + color: $error-text-color; + } + + &.warning { + border-color: $warning-color; + color: $warning-text-color; + } + &:disabled { border-color: $control-disabled-border-color; background-color: $control-disabled-bg-color; @@ -88,6 +99,16 @@ background-color: $primary-dark-color; } + &.error { + border-color: $error-color; + background-color: $error-color; + } + + &.warning { + border-color: $warning-color; + background-color: $warning-color; + } + &:disabled { border-color: $control-disabled-border-color; background-color: $control-disabled-bg-color; diff --git a/src/components/Checkbox/index.jsx b/src/components/Checkbox/index.jsx index da21e84..10e4eb4 100644 --- a/src/components/Checkbox/index.jsx +++ b/src/components/Checkbox/index.jsx @@ -10,6 +10,7 @@ import styles from "./styles.module.scss"; * @param {Object} props component properties * @param {boolean} props.checked whether checkbox is checked * @param {string} [props.className] class name added to root element + * @param {string} [props.impostorClassName] class name added to checkbox impostor * @param {boolean} [props.isDisabled] if checkbox is disabled * @param {string} props.name name for input element * @param {() => void} props.onChange function called when checkbox changes state @@ -21,6 +22,7 @@ import styles from "./styles.module.scss"; const Checkbox = ({ checked, className, + impostorClassName, isDisabled = false, name, onChange, @@ -47,7 +49,7 @@ const Checkbox = ({ checked={checked} value={option ? option.value : ""} /> - + {option && option.label && ( {option.label} )} @@ -57,6 +59,7 @@ const Checkbox = ({ Checkbox.propTypes = { checked: PT.bool, className: PT.string, + impostorClassName: PT.string, isDisabled: PT.bool, name: PT.string.isRequired, size: PT.oneOf(["medium", "small"]), diff --git a/src/components/Checkbox/styles.module.scss b/src/components/Checkbox/styles.module.scss index c07881a..b114fd2 100644 --- a/src/components/Checkbox/styles.module.scss +++ b/src/components/Checkbox/styles.module.scss @@ -96,7 +96,7 @@ input.checkbox { z-index: 2; position: relative; display: inline-block; - vertical-align: -2px; + vertical-align: -3px; width: 15px; height: 15px; line-height: 13px; diff --git a/src/components/Icons/ExclamationMarkCircled/index.jsx b/src/components/Icons/ExclamationMarkCircled/index.jsx new file mode 100644 index 0000000..a7ac21f --- /dev/null +++ b/src/components/Icons/ExclamationMarkCircled/index.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import styles from "./styles.module.scss"; + +/** + * Displays a white exclamation mark inside red circle. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const ExclamationMarkCircled = (props) => ( + +); + +ExclamationMarkCircled.propTypes = { + className: PT.string, +}; + +export default ExclamationMarkCircled; diff --git a/src/components/Icons/ExclamationMarkCircled/styles.module.scss b/src/components/Icons/ExclamationMarkCircled/styles.module.scss new file mode 100644 index 0000000..260762c --- /dev/null +++ b/src/components/Icons/ExclamationMarkCircled/styles.module.scss @@ -0,0 +1,22 @@ +@import "styles/mixins"; +@import "styles/variables"; + +.icon { + display: inline-block; + padding: 2px 0 0; + font-size: 12px; + width: 16px; + height: 16px; + border-radius: 8px; + line-height: 14px; + @include roboto-bold; + text-align: center; + background: $error-color; + color: #fff; + cursor: pointer; + + &::before { + content: "!"; + display: inline; + } +} diff --git a/src/components/Modal/index.jsx b/src/components/Modal/index.jsx new file mode 100644 index 0000000..09ea934 --- /dev/null +++ b/src/components/Modal/index.jsx @@ -0,0 +1,95 @@ +import React from "react"; +import PT from "prop-types"; +import { Modal as ReactModal } from "react-responsive-modal"; +import Button from "components/Button"; +import IconCross from "../../assets/images/icon-cross-light.svg"; +import { stopImmediatePropagation } from "utils/misc"; +import styles from "./styles.module.scss"; +import "react-responsive-modal/styles.css"; + +const classNames = { + modal: styles.modal, + modalContainer: styles.modalContainer, +}; +const closeIcon = ; + +/** + * Displays a modal with Approve- and Dismiss-button and an overlay. + * + * @param {Object} props component properties + * @param {string} [props.approveText] text for Approve-button + * @param {Object} props.children elements that will be shown inside modal + * @param {?Object} [props.controls] custom controls that will be shown below + * modal's contents + * @param {string} [props.dismissText] text for Dismiss-button + * @param {boolean} props.isOpen whether to show or hide the modal + * @param {() => void} [props.onApprove] function called on approve action + * @param {() => void} props.onDismiss function called on dismiss action + * @param {string} [props.title] text for modal title + * @returns {JSX.Element} + */ +const Modal = ({ + approveText = "Apply", + children, + controls, + dismissText = "Cancel", + isOpen, + onApprove, + onDismiss, + title, +}) => ( + +
+ {title &&
{title}
} +
{children}
+ {controls || controls === null ? ( + controls + ) : ( +
+ + +
+ )} + +
+
+); + +Modal.propTypes = { + approveText: PT.string, + children: PT.node, + container: PT.element, + controls: PT.node, + dismissText: PT.string, + isOpen: PT.bool.isRequired, + onApprove: PT.func, + onDismiss: PT.func.isRequired, + title: PT.string, +}; + +export default Modal; diff --git a/src/components/Modal/styles.module.scss b/src/components/Modal/styles.module.scss new file mode 100644 index 0000000..13987c9 --- /dev/null +++ b/src/components/Modal/styles.module.scss @@ -0,0 +1,69 @@ +@import "styles/mixins"; + +div.modalContainer { + padding: 20px; +} + +div.modal { + margin: 0; + border-radius: 8px; + border: none; + padding: 0; + width: 640px; + max-width: 100%; +} + +.wrapper { + padding: 32px 32px 22px; +} + +button.closeButton { + display: inline-block; + position: absolute; + top: 14px; + right: 14px; + border: none; + padding: 0; + width: 15px; + background: transparent; + outline: none !important; + box-shadow: none !important; + + svg { + display: block; + width: 100%; + height: auto; + } +} + +.title { + margin: 0 0 24px; + font-size: 34px; + line-height: 38px; + text-transform: uppercase; + @include barlow-condensed; +} + +.content { + margin: 0 0 10px; + font-size: 16px; + line-height: 22px; + @include roboto-regular; + + + .controls { + margin-top: 24px; + } +} + +.controls { + display: flex; + flex-wrap: wrap; +} + +.button { + margin: 0 10px 10px 0; + + &:last-child { + margin-right: 0; + } +} diff --git a/src/components/Page/styles.module.scss b/src/components/Page/styles.module.scss index 6fecdb9..49417bf 100644 --- a/src/components/Page/styles.module.scss +++ b/src/components/Page/styles.module.scss @@ -7,8 +7,8 @@ display: flex; flex-direction: column; @include roboto-regular; - font-size: 14px; - line-height: (22/14); + font-size: $font-size-px; + line-height: ($line-height-px/$font-size-px); color: $text-color; background-color: $page-bg-color; diff --git a/src/components/Popover/index.jsx b/src/components/Popover/index.jsx new file mode 100644 index 0000000..df5832d --- /dev/null +++ b/src/components/Popover/index.jsx @@ -0,0 +1,84 @@ +import React, { useCallback, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Popup from "components/Popup"; +import { negate, stopPropagation } from "utils/misc"; +import styles from "./styles.module.scss"; + +/** + * Displays a popover with provided content when clicked on the provided + * target children; + * + * @param {Object} props component properties + * @param {Object} props.children target children + * @param {string} [props.className] class name to be added to root element + * @param {Object} props.content content to show in popover + * @param {string} [props.popupClassName] class name to be added to popup + * @param {boolean} [props.stopClickPropagation] whether to prevent propagation + * of click events on target content + * @param {'absolute'|'fixed'} [props.strategy] popup positioning strategy + * @param {string} [props.targetClassName] class name to be added to wrapper + * element around target children + * @returns {JSX.Element} + */ +const Popover = ({ + children, + className, + content, + popupClassName, + stopClickPropagation = false, + strategy = "absolute", + targetClassName, +}) => { + const [isShown, setIsShown] = useState(false); + const [refElem, setRefElem] = useState(null); + + const onTargetClick = useCallback(() => { + setIsShown(negate); + }, []); + + const onClickOutside = useCallback(() => { + setIsShown(false); + }, []); + + return ( +
+ + {children} + + {!!content && isShown && ( + + {content} + + )} +
+ ); +}; + +Popover.propTypes = { + children: PT.node, + className: PT.string, + content: PT.node, + popupClassName: PT.string, + stopClickPropagation: PT.bool, + strategy: PT.oneOf(["absolute", "fixed"]), + targetClassName: PT.string, +}; + +export default Popover; diff --git a/src/components/Popover/styles.module.scss b/src/components/Popover/styles.module.scss new file mode 100644 index 0000000..76ae7b2 --- /dev/null +++ b/src/components/Popover/styles.module.scss @@ -0,0 +1,10 @@ +.container { + position: relative; + display: inline-flex; + align-items: baseline; +} + +.target { + display: inline-flex; + align-items: baseline; +} diff --git a/src/components/Popup/index.jsx b/src/components/Popup/index.jsx index b5be2db..73ad4fe 100644 --- a/src/components/Popup/index.jsx +++ b/src/components/Popup/index.jsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { usePopper } from "react-popper"; import PT from "prop-types"; import cn from "classnames"; +import { useClickOutside } from "utils/hooks"; import compStyles from "./styles.module.scss"; /** @@ -10,6 +11,10 @@ import compStyles from "./styles.module.scss"; * @param {Object} props component properties * @param {any} [props.children] child nodes * @param {string} [props.className] class name to be added to root element + * @param {() => void} [props.onClickOutside] function called when user clicks + * outside the popup + * @param {import('@popperjs/core').Placement} [props.placement] popup placement + * as defined in PopperJS documentation * @param {Object} props.referenceElement reference element * @param {'absolute'|'fixed'} [props.strategy] positioning strategy * @returns {JSX.Element} @@ -17,13 +22,15 @@ import compStyles from "./styles.module.scss"; const Popup = ({ children, className, + onClickOutside, + placement = "bottom", referenceElement, strategy = "absolute", }) => { const [popperElement, setPopperElement] = useState(null); const [arrowElement, setArrowElement] = useState(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: "bottom", + placement, strategy, modifiers: [ { name: "arrow", options: { element: arrowElement, padding: 10 } }, @@ -32,6 +39,8 @@ const Popup = ({ ], }); + useClickOutside(popperElement, onClickOutside, []); + return (
{ const key = event.key; if (key === "Enter" || key === "Escape") { - if (!isMenuFocused || isLoading) { + if (!isMenuFocused) { isChangeAppliedRef.current = true; setIsMenuFocused(false); setIsMenuOpen(false); @@ -159,14 +161,15 @@ const SearchHandleField = ({ const loadOptions = useCallback( throttle( async (value) => { + if (isChangeAppliedRef.current) { + return; + } + setIsLoading(true); + const options = await loadSuggestions(value); if (!isChangeAppliedRef.current) { - setIsLoading(true); - const options = await loadSuggestions(value); - if (!isChangeAppliedRef.current) { - setOptions(options); - setIsLoading(false); - setIsMenuOpen(true); - } + setOptions(options); + setIsLoading(false); + setIsMenuOpen(true); } }, 300, @@ -227,7 +230,7 @@ const loadSuggestions = async (inputValue) => { } try { const res = await getMemberSuggestions(inputValue); - const users = res.data.result.content.slice(0, 100); + const users = res.data.slice(0, 100); let match = null; for (let i = 0, len = users.length; i < len; i++) { let value = users[i].handle; diff --git a/src/components/Spinner/index.jsx b/src/components/Spinner/index.jsx index 57a61cd..afd4885 100644 --- a/src/components/Spinner/index.jsx +++ b/src/components/Spinner/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import cn from "classnames"; +import Loader from "react-loader-spinner"; import styles from "./styles.module.scss"; /** @@ -8,18 +9,31 @@ import styles from "./styles.module.scss"; * * @param {Object} props component props * @param {string} [props.className] class name added to root element - * @param {string} [props.spinnerClassName] class name added to spinner element + * @param {string} [props.color] spinner color in HEX format + * @param {any} [props.type] spinner type as defined in + * react-loader-spinner documentation + * @param {number} [props.width] spinner width + * @param {number} [props.height] spinner height * @returns {JSX.Element} */ -const Spinner = ({ className, spinnerClassName }) => ( +const Spinner = ({ + className, + color = "#00BFFF", + type = "TailSpin", + width = 80, + height = 0, +}) => (
- Loading... +
); Spinner.propTypes = { className: PT.string, - spinnerClassName: PT.string, + color: PT.string, + type: PT.string, + width: PT.number, + height: PT.number, }; export default Spinner; diff --git a/src/components/Spinner/styles.module.scss b/src/components/Spinner/styles.module.scss index 88292f9..824eedd 100644 --- a/src/components/Spinner/styles.module.scss +++ b/src/components/Spinner/styles.module.scss @@ -1,43 +1,7 @@ -@import 'styles/variables'; +@import "styles/variables"; .container { - position: relative; - padding-bottom: 100%; - width: 100%; - height: 0; - overflow: hidden; -} - -.spinner { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - margin: auto; - padding-bottom: 50%; - width: 50%; - height: 0; - color: transparent; - user-select: none; - - &::after { - content: ''; - display: block; - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - border: 10px solid $control-border-color; - border-right-color: transparent; - border-radius: 9999px; - animation: loading-indicator 0.75s linear infinite; - } -} - -@keyframes loading-indicator { - to { - transform: rotate(360deg); - } + display: flex; + flex-direction: column; + align-items: center; } diff --git a/src/components/Tooltip/styles.module.scss b/src/components/Tooltip/styles.module.scss index 3485388..c0d1d6e 100644 --- a/src/components/Tooltip/styles.module.scss +++ b/src/components/Tooltip/styles.module.scss @@ -17,6 +17,23 @@ box-shadow: 0px 5px 25px #c6c6c6; background: #fff; + ul { + margin: 0; + padding: 0; + // list-style: disc inside; + + li { + margin: 0; + padding: 0; + + &::before { + content: "\2022\00A0"; + display: inline; + margin-right: 3px; + } + } + } + .tooltipArrow { display: block; top: 100%; diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 08f0911..4525b94 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -1,5 +1,6 @@ // @ts-ignore import { API } from "../../config"; +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"; @@ -9,6 +10,7 @@ import * as PAYMENT_STATUS from "./workPeriods/paymentStatus"; import * as REASON_DISABLED from "./workPeriods/reasonDisabled"; export { + ALERT, API_CHALLENGE_PAYMENT_STATUS, API_PAYMENT_STATUS, API_SORT_BY, @@ -55,6 +57,7 @@ export const API_REQUIRED_FIELDS = [ "workPeriods.payments.memberRate", "workPeriods.payments.status", "workPeriods.payments.statusDetails", + "workPeriods.payments.workPeriodId", ]; // Valid parameter names for requests. @@ -145,3 +148,8 @@ export const REASON_DISABLED_MESSAGE_MAP = { [REASON_DISABLED.NO_DAYS_TO_PAY_FOR]: "There are no days to pay for", [REASON_DISABLED.NO_MEMBER_RATE]: "Member Rate should be greater than 0", }; + +export const ALERT_MESSAGE_MAP = { + [ALERT.BA_NOT_ASSIGNED]: "BA - Not Assigned", + [ALERT.LAST_BOOKING_WEEK]: "Last Booking Week", +}; diff --git a/src/constants/workPeriods/alerts.js b/src/constants/workPeriods/alerts.js new file mode 100644 index 0000000..cda3a22 --- /dev/null +++ b/src/constants/workPeriods/alerts.js @@ -0,0 +1,2 @@ +export const BA_NOT_ASSIGNED = "BA_NOT_ASSIGNED"; +export const LAST_BOOKING_WEEK = "LAST_BOOKING_WEEK"; diff --git a/src/routes/WorkPeriods/components/PaymentCancel/index.jsx b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx new file mode 100644 index 0000000..187341f --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import PT from "prop-types"; +import cn from "classnames"; +import Button from "components/Button"; +import Modal from "components/Modal"; +import Spinner from "components/Spinner"; +import { makeToast } from "components/ToastrMessage"; +import { PAYMENT_STATUS } from "constants/workPeriods"; +import { setWorkPeriodPaymentData } from "store/actions/workPeriods"; +import { cancelWorkPeriodPayment } from "services/workPeriods"; +import styles from "./styles.module.scss"; +import { loadWorkPeriodAfterPaymentCancel } from "store/thunks/workPeriods"; + +/** + * Displays a Cancel button. Shows a modal with payment cancelling confirmation + * when clicking this button. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {Object} props.item payment object with id, workPeriodId and status + * @param {number} [props.timeout] timeout the delay after cancelling payment + * after which an attempt will be made to update working period's data from the server + * @returns {JSX.Element} + */ +const PaymentCancel = ({ className, item, timeout = 3000 }) => { + const { id: paymentId, workPeriodId: periodId } = item; + const [isModalOpen, setIsModalOpen] = useState(false); + const [isCancelPending, setIsCancelPending] = useState(false); + const [isCancelSuccess, setIsCancelSuccess] = useState(false); + const dispatch = useDispatch(); + + const onApprove = useCallback(() => { + setIsCancelPending(true); + }, []); + + const onDismiss = useCallback(() => { + setIsModalOpen(false); + }, []); + + const openModal = useCallback(() => { + setIsModalOpen(true); + }, []); + + useEffect(() => { + if (isCancelPending) { + cancelWorkPeriodPayment(paymentId) + .then((paymentData) => { + dispatch(setWorkPeriodPaymentData(paymentData)); + setIsCancelSuccess(true); + }) + .catch((error) => { + makeToast(error.toString()); + setIsCancelPending(false); + }); + } + }, [isCancelPending, paymentId, dispatch]); + + useEffect(() => { + let timeoutId = 0; + if (isCancelSuccess) { + timeoutId = window.setTimeout(async () => { + timeoutId = 0; + await dispatch(loadWorkPeriodAfterPaymentCancel(periodId, paymentId)); + setIsModalOpen(false); + setIsCancelSuccess(false); + setIsCancelPending(false); + }, timeout); + } + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [isCancelSuccess, paymentId, periodId, timeout, dispatch]); + + let title, controls; + if (isCancelPending) { + controls = null; + title = "Marking as cancelled..."; + } else { + controls = undefined; + title = "Warning!"; + } + + return ( +
+ + + {isCancelPending ? ( + + ) : ( + `Cancelling payment here will only mark it as cancelled in TaaS system. + Before cancelling it here, make sure that actual payment is cancelled in + PACTS first, and only after that you may mark it as cancelled here.` + )} + +
+ ); +}; + +PaymentCancel.propTypes = { + className: PT.string, + item: PT.shape({ + id: PT.string.isRequired, + status: PT.string.isRequired, + workPeriodId: PT.string.isRequired, + }).isRequired, + timeout: PT.number, +}; + +export default PaymentCancel; diff --git a/src/routes/WorkPeriods/components/PaymentCancel/styles.module.scss b/src/routes/WorkPeriods/components/PaymentCancel/styles.module.scss new file mode 100644 index 0000000..7b5a0a2 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentCancel/styles.module.scss @@ -0,0 +1,3 @@ +.container { + display: inline-block; +} diff --git a/src/routes/WorkPeriods/components/PaymentError/index.jsx b/src/routes/WorkPeriods/components/PaymentError/index.jsx index 81557e8..097301e 100644 --- a/src/routes/WorkPeriods/components/PaymentError/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentError/index.jsx @@ -1,10 +1,9 @@ -import React, { useCallback, useRef, useState } from "react"; +import React, { useMemo } from "react"; import PT from "prop-types"; import cn from "classnames"; -import Popup from "components/Popup"; +import Popover from "components/Popover"; +import IconExclamationMark from "components/Icons/ExclamationMarkCircled"; import PaymentErrorDetails from "../PaymentErrorDetails"; -import { useClickOutside } from "utils/hooks"; -import { negate } from "utils/misc"; import styles from "./styles.module.scss"; /** @@ -23,36 +22,21 @@ const PaymentError = ({ isImportant = true, popupStrategy = "absolute", }) => { - const [isShowPopup, setIsShowPopup] = useState(false); - const [refElem, setRefElem] = useState(null); - const containerRef = useRef(null); - - const onIconClick = useCallback((event) => { - event.stopPropagation(); - setIsShowPopup(negate); - }, []); - - const onClickOutside = useCallback(() => { - setIsShowPopup(false); - }, []); - - useClickOutside(containerRef, onClickOutside, []); - + const paymentErrorDetails = useMemo( + () => , + [errorDetails] + ); return ( -
- + - {isShowPopup && errorDetails && ( - - - - )} -
+ ); }; diff --git a/src/routes/WorkPeriods/components/PaymentError/styles.module.scss b/src/routes/WorkPeriods/components/PaymentError/styles.module.scss index ff2dbb1..00a44b9 100644 --- a/src/routes/WorkPeriods/components/PaymentError/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentError/styles.module.scss @@ -6,26 +6,9 @@ } .icon { - display: inline-block; - padding: 2px 0 0; - font-size: 12px; - width: 16px; - height: 16px; - border-radius: 8px; - line-height: 14px; - text-align: center; - background: $error-color; - color: #fff; opacity: 0.3; - cursor: pointer; &.isImportant { opacity: 1; } - - &::before { - content: "!"; - display: inline; - font-weight: 700; - } } diff --git a/src/routes/WorkPeriods/components/PaymentTotal/index.jsx b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx index 8b2d1cb..3dc104d 100644 --- a/src/routes/WorkPeriods/components/PaymentTotal/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx @@ -1,12 +1,9 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -import React, { useCallback, useRef, useState } from "react"; +import React, { useMemo } from "react"; import PT from "prop-types"; import cn from "classnames"; -import Popup from "components/Popup"; +import Popover from "components/Popover"; import PaymentsList from "../PaymentsList"; -import { useClickOutside } from "utils/hooks"; import { currencyFormatter } from "utils/formatters"; -import { negate, stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; /** @@ -27,47 +24,29 @@ const PaymentTotal = ({ daysPaid, popupStrategy = "absolute", }) => { - const [isShowPopup, setIsShowPopup] = useState(false); - const [refElem, setRefElem] = useState(null); - const containerRef = useRef(null); - - const onWeeklyRateClick = useCallback(() => { - setIsShowPopup(negate); - }, []); - - const onClickOutside = useCallback(() => { - setIsShowPopup(false); - }, []); - const hasPayments = !!payments && !!payments.length; - useClickOutside(containerRef, onClickOutside, []); + const paymentsList = useMemo( + () => (hasPayments ? : null), + [hasPayments, payments] + ); return ( -
- - - {currencyFormatter.format(paymentTotal)} - -   - ({daysPaid}) + + {currencyFormatter.format(paymentTotal)} - {hasPayments && isShowPopup && ( - - - - )} -
+   + ({daysPaid}) + ); }; diff --git a/src/routes/WorkPeriods/components/PaymentsList/index.jsx b/src/routes/WorkPeriods/components/PaymentsList/index.jsx index 7391317..580a9eb 100644 --- a/src/routes/WorkPeriods/components/PaymentsList/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsList/index.jsx @@ -19,7 +19,8 @@ const PaymentsList = ({ className, payments }) => ( Weekly Rate Days Amount - Status + Status + diff --git a/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss index a878a33..c8b9cec 100644 --- a/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss @@ -13,29 +13,35 @@ table.paymentsList { margin-top: 5px; - th { - @include roboto-bold; - padding: 10px 7px; - font-size: 12px; - line-height: 16px; - white-space: nowrap; - text-align: right; - background: #f4f4f4; + > thead { + > tr { + > th { + @include roboto-bold; + padding: 10px 7px; + font-size: 12px; + line-height: 16px; + white-space: nowrap; + text-align: right; + background: #f4f4f4; - &:first-child, - &:last-child { - text-align: left; - } + &:first-child, + &.paymentStatus { + text-align: left; + } - &:first-child { - padding-left: 28px; + &:first-child { + padding-left: 28px; + } + } } } - tr { - td { - padding: 5px 7px; - white-space: nowrap; + > tbody { + > tr { + > td { + padding: 5px 7px; + white-space: nowrap; + } } } } diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx index 939f998..4b01148 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -2,11 +2,12 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ import React, { useCallback, useRef } from "react"; import PT from "prop-types"; +import PaymentCancel from "../PaymentCancel"; +import PaymentError from "../PaymentError"; +import PaymentStatus from "../PaymentStatus"; import { currencyFormatter, formatChallengeUrl } from "utils/formatters"; import { PAYMENT_STATUS } from "constants/workPeriods"; -import PaymentStatus from "../PaymentStatus"; import styles from "./styles.module.scss"; -import PaymentError from "../PaymentError"; const PaymentsListItem = ({ item }) => { const inputRef = useRef(); @@ -58,6 +59,9 @@ const PaymentsListItem = ({ item }) => { )}
+ + + ); }; diff --git a/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx b/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx new file mode 100644 index 0000000..896b77a --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx @@ -0,0 +1,56 @@ +import React, { useMemo } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Tooltip from "components/Tooltip"; +import { ALERT_MESSAGE_MAP } from "constants/workPeriods"; +import styles from "./styles.module.scss"; + +/** + * Displays alerts for working period. + * + * @param {Object} props component properties + * @param {string[]} [props.alerts] array of alert ids + * @param {string} [props.className] class name to be added to alerts wrapper + * @returns {JSX.Element} + */ +const PeriodAlerts = ({ alerts, className }) => { + const alertsTooltipContent = useMemo(() => { + if (!alerts) { + return null; + } + if (alerts.length === 1) { + return ALERT_MESSAGE_MAP[alerts[0]]; + } + return ( +
    + {alerts.map((alertId) => ( +
  • {ALERT_MESSAGE_MAP[alertId]}
  • + ))} +
+ ); + }, [alerts]); + + return ( + + {alerts + ? alerts.map((alertId) => ALERT_MESSAGE_MAP[alertId]).join(", ") + : "None"} + + ); +}; + +PeriodAlerts.propTypes = { + alerts: PT.arrayOf(PT.string), + className: PT.string, +}; + +export default PeriodAlerts; diff --git a/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss b/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss new file mode 100644 index 0000000..ed78d8f --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss @@ -0,0 +1,40 @@ +@import "styles/mixins"; +@import "styles/variables"; + +.container { + display: inline-block; + + &.hasAlerts { + border-radius: 5px; + padding: 3px 5px 1px; + height: 20px; + max-width: 15em; + line-height: 16px; + font-size: 11px; + @include roboto-medium; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background-color: #ffc43d; + + &::before { + content: "!"; + display: inline-block; + margin-right: 4px; + border: 2px solid $text-color; + border-radius: 7px; + padding: 1px 0 0; + width: 13px; + height: 13px; + line-height: 8px; + font-size: 10px; + @include roboto-bold; + text-align: center; + } + } +} + +.tooltip { + white-space: nowrap; +} diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx index 809cea2..8f28cd6 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -26,15 +26,19 @@ import styles from "./styles.module.scss"; * @param {Object} props.details working period details object * @param {boolean} props.isDisabled whether the details are disabled * @param {boolean} props.isFailed whether the payments for the period has failed + * @param {Object} props.period working period basic data object * @returns {JSX.Element} */ -const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { +const PeriodDetails = ({ + className, + details, + isDisabled, + isFailed, + period, +}) => { const dispatch = useDispatch(); + const { id: periodId, rbId, jobId, billingAccountId } = period; const { - periodId, - rbId, - jobId, - billingAccountId, billingAccounts, billingAccountsError, billingAccountsIsDisabled, @@ -85,7 +89,7 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { )} > {periodsIsLoading ? ( - +
Loading...
) : ( @@ -118,7 +122,7 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { - +
History @@ -153,13 +157,6 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { PeriodDetails.propTypes = { className: PT.string, details: PT.shape({ - periodId: PT.string.isRequired, - rbId: PT.string.isRequired, - jobId: PT.string.isRequired, - jobName: PT.string, - jobNameError: PT.string, - jobNameIsLoading: PT.bool.isRequired, - billingAccountId: PT.number.isRequired, billingAccounts: PT.arrayOf( PT.shape({ label: PT.string.isRequired, @@ -175,6 +172,12 @@ PeriodDetails.propTypes = { }).isRequired, isDisabled: PT.bool.isRequired, isFailed: PT.bool.isRequired, + period: PT.shape({ + id: PT.string.isRequired, + rbId: PT.string.isRequired, + jobId: PT.string.isRequired, + billingAccountId: PT.number.isRequired, + }).isRequired, }; export default memo(PeriodDetails); diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 0419f24..c1b7ab6 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -12,6 +12,7 @@ import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; import PeriodWorkingDays from "../PeriodWorkingDays"; import PeriodDetails from "../PeriodDetails"; +import ProcessingError from "../ProcessingError"; import { PAYMENT_STATUS, REASON_DISABLED_MESSAGE_MAP, @@ -29,27 +30,30 @@ import { useUpdateEffect } from "utils/hooks"; import { formatUserHandleLink, formatWeeklyRate } from "utils/formatters"; import { stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; +import PeriodAlerts from "../PeriodAlerts"; /** * Displays the working period data row to be used in PeriodList component. * * @param {Object} props component properties * @param {boolean} [props.isDisabled] whether the item is disabled - * @param {boolean} [props.isFailed] whether the item should be highlighted as failed * @param {boolean} props.isSelected whether the item is selected * @param {Object} props.item object describing a working period + * @param {Array} [props.alerts] array with alert ids * @param {Object} props.data changeable working period data such as working days * @param {Object} [props.details] object with working period details + * @param {Object} [props.reasonFailed] error object denoting payment processing failure * @param {Array} [props.reasonsDisabled] array of REASON_DISABLED values. * @returns {JSX.Element} */ const PeriodItem = ({ isDisabled = false, - isFailed = false, isSelected, item, + alerts, data, details, + reasonFailed, reasonsDisabled, }) => { const dispatch = useDispatch(); @@ -114,9 +118,9 @@ const PeriodItem = ({ const reasonsDisabledElement = useMemo( () => ( - +
{formatReasonsDisabled(reasonsDisabled)} - +
), [reasonsDisabled] ); @@ -126,7 +130,7 @@ const PeriodItem = ({ @@ -138,6 +142,7 @@ const PeriodItem = ({ targetClassName={styles.checkboxContainer} > + + {reasonFailed && ( + + )} + {item.startDate} {item.endDate} + + + {formatWeeklyRate(item.weeklyRate)} @@ -207,24 +220,49 @@ const PeriodItem = ({ {details && ( )} ); }; +/** + * Returns a string produced by concatenation of all provided reasons some + * working period is disabled. + * + * @param {Array} reasonIds array of REASON_DISABLED values + * @returns {any} + */ +function formatReasonsDisabled(reasonIds) { + if (!reasonIds) { + return null; + } + if (reasonIds.length === 1) { + return REASON_DISABLED_MESSAGE_MAP[reasonIds[0]]; + } + const reasons = []; + for (let i = 0, len = reasonIds.length; i < len; i++) { + let reasonId = reasonIds[i]; + reasons.push( +
  • {REASON_DISABLED_MESSAGE_MAP[reasonId]}
  • + ); + } + return
      {reasons}
    ; +} + PeriodItem.propTypes = { className: PT.string, isDisabled: PT.bool, - isFailed: PT.bool, isSelected: PT.bool.isRequired, item: PT.shape({ id: PT.oneOfType([PT.number, PT.string]).isRequired, - jobId: PT.string.isRequired, + jobId: PT.string, rbId: PT.string.isRequired, + billingAccountId: PT.number.isRequired, projectId: PT.oneOfType([PT.number, PT.string]).isRequired, userHandle: PT.string.isRequired, teamName: PT.oneOfType([PT.number, PT.string]).isRequired, @@ -232,6 +270,7 @@ PeriodItem.propTypes = { endDate: PT.string.isRequired, weeklyRate: PT.number, }).isRequired, + alerts: PT.arrayOf(PT.string), data: PT.shape({ daysWorked: PT.number.isRequired, daysPaid: PT.number.isRequired, @@ -241,11 +280,6 @@ PeriodItem.propTypes = { paymentTotal: PT.number.isRequired, }).isRequired, details: PT.shape({ - periodId: PT.string.isRequired, - rbId: PT.string.isRequired, - jobName: PT.string.isRequired, - jobNameIsLoading: PT.bool.isRequired, - billingAccountId: PT.number.isRequired, billingAccounts: PT.arrayOf( PT.shape({ label: PT.string.isRequired, @@ -256,27 +290,8 @@ PeriodItem.propTypes = { periods: PT.array.isRequired, periodsIsLoading: PT.bool.isRequired, }), + reasonFailed: PT.object, reasonsDisabled: PT.arrayOf(PT.string), }; export default memo(PeriodItem); - -/** - * Returns a string produced by concatenation of all provided reasons some - * working period is disabled. - * - * @param {Array} reasonIds array of REASON_DISABLED values - * @returns {?Array} - */ -function formatReasonsDisabled(reasonIds) { - if (!reasonIds) { - return null; - } - const reasons = []; - reasons.push('– ' + REASON_DISABLED_MESSAGE_MAP[reasonIds[0]]); - for (let i = 1, len = reasonIds.length; i < len; i++) { - reasons.push(
    ); - reasons.push('– ' + REASON_DISABLED_MESSAGE_MAP[reasonIds[i]]); - } - return reasons; -} diff --git a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss index bbb9bd9..921583c 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss @@ -57,6 +57,17 @@ td.toggle { padding: 12px 18px 12px 15px; line-height: 15px; + white-space: nowrap; +} + +.selectionCheckbox { + display: inline-block; +} + +.processingError { + display: inline-block; + margin-left: 10px; + width: 15px; } .userHandle { diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index 09a3f2a..f1b0e76 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -8,6 +8,7 @@ import PeriodItem from "../PeriodItem"; import PeriodListHead from "../PeriodListHead"; import { getWorkPeriods, + getWorkPeriodsAlerts, getWorkPeriodsData, getWorkPeriodsDetails, getWorkPeriodsDisabled, @@ -26,6 +27,7 @@ import styles from "./styles.module.scss"; */ const PeriodList = ({ className }) => { const periods = useSelector(getWorkPeriods); + const periodsAlerts = useSelector(getWorkPeriodsAlerts); const [periodsData] = useSelector(getWorkPeriodsData); const periodsDetails = useSelector(getWorkPeriodsDetails); const [periodsDisabledMap] = useSelector(getWorkPeriodsDisabled); @@ -49,17 +51,18 @@ const PeriodList = ({ className }) => { - + {periods.map((period) => ( ))} diff --git a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx index 12b987e..ee70844 100644 --- a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx @@ -75,6 +75,7 @@ const HEAD_CELLS = [ { label: "Team Name", id: SORT_BY.TEAM_NAME, disableSort: true }, { label: "Start Date", id: SORT_BY.START_DATE, className: "startDate" }, { label: "End Date", id: SORT_BY.END_DATE, className: "endDate" }, + { label: "Alert", id: SORT_BY.ALERT, disableSort: true, className: "alert" }, { label: "Weekly Rate", id: SORT_BY.WEEKLY_RATE, className: "weeklyRate" }, { label: "Total Paid", id: SORT_BY.PAYMENT_TOTAL, className: "totalPaid" }, { label: "Status", id: SORT_BY.PAYMENT_STATUS }, diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx index c8ae8b7..a1d29e3 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx @@ -4,7 +4,7 @@ import PT from "prop-types"; import cn from "classnames"; import debounce from "lodash/debounce"; import moment from "moment"; -import PaymentError from "../PaymentError"; +import ProcessingError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; import PeriodWorkingDays from "../PeriodWorkingDays"; @@ -72,7 +72,7 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { {dateLabel} {data.paymentErrorLast && ( - ( + + + +); + +ProcessingError.propTypes = { + className: PT.string, + error: PT.object, + popupStrategy: PT.oneOf(["absolute", "fixed"]), +}; + +export default ProcessingError; diff --git a/src/routes/WorkPeriods/components/ProcessingError/styles.module.scss b/src/routes/WorkPeriods/components/ProcessingError/styles.module.scss new file mode 100644 index 0000000..13e402a --- /dev/null +++ b/src/routes/WorkPeriods/components/ProcessingError/styles.module.scss @@ -0,0 +1,18 @@ +@import "styles/variables"; + +.container { + display: inline-block; + position: relative; +} + +.popup { + max-width: 400px; + line-height: $line-height-px; + white-space: normal; +} + +.icon { + padding-top: 1px; + width: 15px; + height: 15px; +} diff --git a/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss b/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss index 5b0aebb..94d0f58 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss +++ b/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss @@ -3,5 +3,5 @@ } .sectionSucceeded { - margin-bottom: 5px; + margin-bottom: 0; } diff --git a/src/services/teams.js b/src/services/teams.js index 1ffede2..d9606b6 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -9,5 +9,5 @@ import config from "../../config"; * @returns {Promise} */ export const getMemberSuggestions = (fragment) => { - return axios.get(`${config.API.V3}/members/_suggest/${fragment}`); + return axios.get(`${config.API.V5}/taas-teams/members-suggest/${fragment}`); }; diff --git a/src/services/workPeriods.js b/src/services/workPeriods.js index 6c3780f..4c8d320 100644 --- a/src/services/workPeriods.js +++ b/src/services/workPeriods.js @@ -1,12 +1,13 @@ import axios, { CancelToken } from "./axios"; import { - RB_API_URL, + API_CHALLENGE_PAYMENT_STATUS, + API_QUERY_PARAM_NAMES, JOBS_API_URL, PAYMENTS_API_URL, PROJECTS_API_URL, - API_QUERY_PARAM_NAMES, - WORK_PERIODS_API_URL, + RB_API_URL, TAAS_TEAM_API_URL, + WORK_PERIODS_API_URL, } from "constants/workPeriods"; import { buildRequestQuery, extractResponseData } from "utils/misc"; @@ -101,14 +102,22 @@ export const fetchResourceBookings = (params) => { return [ axios.get( `${RB_API_URL}?${buildRequestQuery(params, API_QUERY_PARAM_NAMES)}`, - { - cancelToken: source.token, - } + { cancelToken: source.token } ), source, ]; }; +export const fetchWorkPeriod = (periodId) => { + const source = CancelToken.source(); + return [ + axios + .get(`${WORK_PERIODS_API_URL}/${periodId}`, { cancelToken: source.token }) + .then(extractResponseData), + source, + ]; +}; + /** * Updates working period's working days. * @@ -141,6 +150,20 @@ export const patchWorkPeriodBillingAccount = (rbId, billingAccountId) => { return axios.patch(`${RB_API_URL}/${rbId}`, { billingAccountId }); }; +/** + * Sends request to cancel specific working period's payment. + * + * @param {string} paymentId payment id + * @returns {Promise} + */ +export const cancelWorkPeriodPayment = (paymentId) => { + return axios + .patch(`${PAYMENTS_API_URL}/${paymentId}`, { + status: API_CHALLENGE_PAYMENT_STATUS.CANCELLED, + }) + .then(extractResponseData); +}; + /** * Sends request to queue payments for specific working periods and amounts * inside the provided array. diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 89d986c..d7fb1ac 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -18,6 +18,7 @@ export const WP_SET_DETAILS_HIDE_PAST_PERIODS = export const WP_SET_PAGE_NUMBER = "WP_SET_PAGE_NUMBER"; export const WP_SET_PAGE_SIZE = "WP_SET_PAGE_SIZE"; export const WP_SET_DATE_RANGE = "WP_SET_DATE_RANGE"; +export const WP_SET_PAYMENT_DATA = "WP_SET_PAYMENT_DATA"; export const WP_SET_PAYMENT_STATUSES = "WP_SET_PAYMENT_STATUSES"; export const WP_SET_PERIOD_DATA_PENDING = "WP_SET_PERIOD_DATA_PENDING"; export const WP_SET_PERIOD_DATA_SUCCESS = "WP_SET_PERIOD_DATA_SUCCESS"; @@ -27,6 +28,9 @@ export const WP_SET_SORT_ORDER = "WP_SET_SORT_ORDER"; export const WP_SET_SORTING = "WP_SET_SORTING"; export const WP_SET_USER_HANDLE = "WP_SET_USER_HANDLE"; export const WP_SET_WORKING_DAYS = "WP_SET_WORKING_DAYS"; +export const WP_SET_WORKING_DAYS_PENDING = "WP_SET_WORKING_DAYS_PENDING"; +export const WP_SET_WORKING_DAYS_SUCCESS = "WP_SET_WORKING_DAYS_SUCCESS"; +export const WP_SET_WORKING_DAYS_ERROR = "WP_SET_WORKING_DAYS_ERROR"; export const WP_TOGGLE_ONLY_FAILED_PAYMENTS = "WP_TOGGLE_ONLY_FAILED_PAYMENTS"; export const WP_TOGGLE_PERIOD = "WP_TOGGLE_PERIOD"; export const WP_TOGGLE_PERIODS_ALL = "WP_TOGGLE_PERIODS_ALL"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index 84e54be..11f83cc 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -92,25 +92,25 @@ export const loadWorkPeriodDetailsError = (periodId, message) => ({ /** * Creates an action denoting successful load of billing accounts. * - * @param {string} periodId working period id + * @param {Object} period working period basic data object * @param {Array} accounts billing accounts * @returns {Object} */ -export const loadBillingAccountsSuccess = (periodId, accounts) => ({ +export const loadBillingAccountsSuccess = (period, accounts) => ({ type: ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_SUCCESS, - payload: { periodId, accounts }, + payload: { period, accounts }, }); /** * Creates an action denoting an error while loading billing accounts. * - * @param {string} periodId working period id + * @param {Object} period working period basic data object * @param {string} message error message * @returns {Object} */ -export const loadBillingAccountsError = (periodId, message) => ({ +export const loadBillingAccountsError = (period, message) => ({ type: ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_ERROR, - payload: { periodId, message, id: nextErrorId++ }, + payload: { period, message, id: nextErrorId++ }, }); /** @@ -276,6 +276,34 @@ export const setWorkPeriodsUserHandle = (handle) => ({ payload: handle, }); +/** + * Creates an action denoting an attempt to update working period's data + * on the server. + * + * @param {Object} periodId working period id + * @param {Object} cancelSource axios cancel token source + * @returns {Object} + */ +export const setWorkPeriodDataPending = (periodId, cancelSource) => ({ + type: ACTION_TYPE.WP_SET_PERIOD_DATA_PENDING, + payload: { periodId, cancelSource }, +}); + +export const setWorkPeriodDataSuccess = (periodId, data) => ({ + type: ACTION_TYPE.WP_SET_PERIOD_DATA_SUCCESS, + payload: { periodId, data }, +}); + +export const setWorkPeriodDataError = (periodId, message) => ({ + type: ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR, + payload: { periodId, message }, +}); + +export const setWorkPeriodPaymentData = (paymentData) => ({ + type: ACTION_TYPE.WP_SET_PAYMENT_DATA, + payload: paymentData, +}); + /** * Creates an action to change working days for specific working period. * @@ -289,24 +317,25 @@ export const setWorkPeriodWorkingDays = (periodId, daysWorked) => ({ }); /** - * Creates an action denoting the update of working period's changeable data. + * Creates an action denoting an attempt to update working period's working days + * on the server. * * @param {Object} periodId working period id * @param {Object} cancelSource axios cancel token source * @returns {Object} */ -export const setWorkPeriodDataPending = (periodId, cancelSource) => ({ - type: ACTION_TYPE.WP_SET_PERIOD_DATA_PENDING, +export const setWorkPeriodWorkingDaysPending = (periodId, cancelSource) => ({ + type: ACTION_TYPE.WP_SET_WORKING_DAYS_PENDING, payload: { periodId, cancelSource }, }); -export const setWorkPeriodDataSuccess = (periodId, data) => ({ - type: ACTION_TYPE.WP_SET_PERIOD_DATA_SUCCESS, +export const setWorkPeriodWorkingDaysSuccess = (periodId, data) => ({ + type: ACTION_TYPE.WP_SET_WORKING_DAYS_SUCCESS, payload: { periodId, data }, }); -export const setWorkPeriodDataError = (periodId, message) => ({ - type: ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR, +export const setWorkPeriodWorkingDaysError = (periodId, message) => ({ + type: ACTION_TYPE.WP_SET_WORKING_DAYS_ERROR, payload: { periodId, message }, }); diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index b5da291..12c0d8c 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -11,6 +11,7 @@ import { SORT_ORDER_DEFAULT, URL_QUERY_PARAM_MAP, REASON_DISABLED, + ALERT, } from "constants/workPeriods"; import { filterPeriodsByStartDate, @@ -18,10 +19,11 @@ import { updateOptionMap, } from "utils/misc"; import { - addReasonDisabled, + addValueImmutable, + createPeriodAlerts, createAssignedBillingAccountOption, findReasonsDisabled, - removeReasonDisabled, + removeValueImmutable, } from "utils/workPeriods"; const cancelSourceDummy = { cancel: () => {} }; @@ -49,14 +51,13 @@ const initPeriodData = (period) => { return data; }; -const initPeriodDetails = (period, cancelSource = cancelSourceDummy) => ({ - periodId: period.id, - rbId: period.rbId, +const initPeriodDetails = ( + billingAccountId = 0, + cancelSource = cancelSourceDummy +) => ({ cancelSource, - jobId: period.jobId, - billingAccountId: period.billingAccountId || 0, billingAccounts: [ - { value: period.billingAccountId || 0, label: BILLING_ACCOUNTS_LOADING }, + { value: billingAccountId, label: BILLING_ACCOUNTS_LOADING }, ], billingAccountsError: null, billingAccountsIsDisabled: true, @@ -76,6 +77,7 @@ const initialState = updateStateFromQuery(window.location.search, { isSelectedPeriodsVisible: false, pagination: initPagination(), periods: [], + periodsAlerts: {}, periodsById: {}, periodsData: [{}], periodsDetails: {}, @@ -103,6 +105,7 @@ const actionHandlers = { isSelectedPeriodsAll: false, isSelectedPeriodsVisible: false, periods: [], + periodsAlerts: {}, periodsById: {}, periodsData: [{}], periodsDetails: {}, @@ -120,9 +123,11 @@ const actionHandlers = { oldPagination.pageCount !== pageCount ? { ...oldPagination, totalCount, pageCount } : oldPagination; + const periodsAlerts = {}; const periodsById = {}; const periodsData = {}; const periodsDisabledMap = new Map(); + const periodEndDate = state.filters.dateRange[1]; for (let period of periods) { periodsById[period.id] = true; periodsData[period.id] = initPeriodData(period); @@ -130,6 +135,10 @@ const actionHandlers = { if (reasonsDisabled) { periodsDisabledMap.set(period.id, reasonsDisabled); } + let alerts = createPeriodAlerts(period, periodEndDate); + if (alerts) { + periodsAlerts[period.id] = alerts; + } delete period.data; } return { @@ -138,6 +147,7 @@ const actionHandlers = { error: null, pagination, periods, + periodsAlerts, periodsById, periodsData: [periodsData], periodsDisabled: [periodsDisabledMap], @@ -164,12 +174,13 @@ const actionHandlers = { if (!periodIds.length) { return state; } - const periodsFailed = { ...state.periodsFailed }; + const periodsFailed = {}; const periodsSelectedSet = state.periodsSelected[0]; const oldPeriodsSelectedCount = periodsSelectedSet.size; for (let periodId of periodIds) { - if (periods[periodId]) { - periodsFailed[periodId] = true; + let error = periods[periodId]; + if (error) { + periodsFailed[periodId] = error; periodsSelectedSet.add(periodId); } else { periodsSelectedSet.delete(periodId); @@ -190,7 +201,10 @@ const actionHandlers = { { period, cancelSource } ) => { const periodsDetails = { ...state.periodsDetails }; - periodsDetails[period.id] = initPeriodDetails(period, cancelSource); + periodsDetails[period.id] = initPeriodDetails( + period.billingAccountId, + cancelSource + ); return { ...state, periodsDetails, @@ -200,7 +214,7 @@ const actionHandlers = { state, { periodId, details } ) => { - const periodsDetails = { ...state.periodsDetails }; + const periodsDetails = state.periodsDetails; let periodDetails = periodsDetails[periodId]; // period details object must already be initialized if (!periodDetails) { @@ -231,7 +245,7 @@ const actionHandlers = { return { ...state, periodsData: [periodsData], - periodsDetails, + periodsDetails: { ...periodsDetails }, }; }, [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_ERROR]: ( @@ -249,16 +263,26 @@ const actionHandlers = { }, [ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_SUCCESS]: ( state, - { periodId, accounts } + { period, accounts } ) => { - const periodsDetails = { ...state.periodsDetails }; - let periodDetails = periodsDetails[periodId]; + const periodsDetails = state.periodsDetails; + let periodDetails = periodsDetails[period.id]; if (!periodDetails) { // Period details may be removed at this point so we must handle this case. return state; } + let accountId = period.billingAccountId; + let hasAssignedAccount = false; + for (let account of accounts) { + if (account.value === accountId) { + hasAssignedAccount = true; + break; + } + } + if (accountId > 0 && !hasAssignedAccount) { + accounts.unshift(createAssignedBillingAccountOption(accountId)); + } let billingAccountsIsDisabled = false; - let accountId = periodDetails.billingAccountId; if (!accounts.length) { accounts.push({ value: accountId, label: BILLING_ACCOUNTS_NONE }); billingAccountsIsDisabled = true; @@ -273,24 +297,24 @@ const actionHandlers = { if (!periodDetails.periodsIsLoading) { periodDetails.cancelSource = null; } - periodsDetails[periodId] = periodDetails; + periodsDetails[period.id] = periodDetails; return { ...state, - periodsDetails, + periodsDetails: { ...periodsDetails }, }; }, [ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_ERROR]: ( state, - { periodId, message } + { period, message } ) => { - const periodsDetails = { ...state.periodsDetails }; - let periodDetails = periodsDetails[periodId]; + const periodsDetails = state.periodsDetails; + let periodDetails = periodsDetails[period.id]; if (!periodDetails) { return state; } let billingAccounts = []; let billingAccountsIsDisabled = true; - let accountId = periodDetails.billingAccountId; + let accountId = period.billingAccountId; if (accountId) { billingAccounts.push(createAssignedBillingAccountOption(accountId)); billingAccountsIsDisabled = false; @@ -307,30 +331,29 @@ const actionHandlers = { if (!periodDetails.periodsIsLoading) { periodDetails.cancelSource = null; } - periodsDetails[periodId] = periodDetails; + periodsDetails[period.id] = periodDetails; return { ...state, - periodsDetails, + periodsDetails: { ...periodsDetails }, }; }, [ACTION_TYPE.WP_SET_BILLING_ACCOUNT]: (state, { periodId, accountId }) => { - let periodsDetails = state.periodsDetails; - const periodDetails = periodsDetails[periodId]; - if (!periodDetails) { - return state; + const periods = state.periods; + for (let i = 0, len = periods.length; i < len; i++) { + let period = periods[i]; + if (period.id === periodId) { + periods[i] = { ...period, billingAccountId: accountId }; + break; + } } - periodsDetails[periodId] = { - ...periodDetails, - billingAccountId: accountId, - }; - periodsDetails = { ...periodsDetails }; state = { ...state, - periodsDetails, + periods: [...periods], }; + // updating reasons for which the period's selection may be disabled const periodsDisabledMap = state.periodsDisabled[0]; const oldReasonsDisabled = periodsDisabledMap.get(periodId); - const reasonsDisabled = removeReasonDisabled( + const reasonsDisabled = removeValueImmutable( oldReasonsDisabled, REASON_DISABLED.NO_BILLING_ACCOUNT ); @@ -343,6 +366,18 @@ const actionHandlers = { state.periodsDisabled = [periodsDisabledMap]; updateSelectedPeriodsFlags(state); } + // updating period's alerts + const periodsAlerts = state.periodsAlerts; + const oldAlerts = periodsAlerts[periodId]; + const alerts = removeValueImmutable(oldAlerts, ALERT.BA_NOT_ASSIGNED); + if (oldAlerts !== alerts) { + if (alerts) { + periodsAlerts[periodId] = alerts; + } else { + delete periodsAlerts[periodId]; + } + state.periodsAlerts = { ...periodsAlerts }; + } return state; }, [ACTION_TYPE.WP_SET_DETAILS_HIDE_PAST_PERIODS]: ( @@ -519,7 +554,6 @@ const actionHandlers = { periodsData[periodId] = { ...periodData, cancelSource, - daysWorkedIsUpdated: false, }; return { ...state, @@ -532,11 +566,10 @@ const actionHandlers = { if (!periodData) { return state; } - periodData = periodsData[periodId] = { + periodsData[periodId] = { ...periodData, ...data, cancelSource: null, - daysWorkedIsUpdated: true, }; state = { ...state, @@ -546,7 +579,8 @@ const actionHandlers = { ? updateStateAfterWorkingDaysChange(periodId, state) : state; }, - [ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR]: (state, { periodId }) => { + [ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR]: (state, { periodId, message }) => { + console.error(message); const periodsData = state.periodsData[0]; const periodData = periodsData[periodId]; if (!periodData) { @@ -555,7 +589,35 @@ const actionHandlers = { periodsData[periodId] = { ...periodData, cancelSource: null, - daysWorkedIsUpdated: false, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, + [ACTION_TYPE.WP_SET_PAYMENT_DATA]: (state, paymentData) => { + const periodId = paymentData.workPeriodId; + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + const paymentId = paymentData.id; + const payments = periodData.payments; + let lastFailedPayment = null; + for (let i = 0, len = payments.length; i < len; i++) { + let payment = payments[i]; + if (payment.id === paymentId) { + payments[i] = paymentData; + periodData.payments = [...payments]; + } + if (payment.status === PAYMENT_STATUS.FAILED) { + lastFailedPayment = payment; + } + } + periodsData[periodId] = { + ...periodData, + paymentErrorLast: lastFailedPayment?.statusDetails, }; return { ...state, @@ -578,6 +640,62 @@ const actionHandlers = { periodsData: [periodsData], }); }, + [ACTION_TYPE.WP_SET_WORKING_DAYS_PENDING]: ( + state, + { periodId, cancelSource } + ) => { + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + periodsData[periodId] = { + ...periodData, + cancelSource, + daysWorkedIsUpdated: false, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, + [ACTION_TYPE.WP_SET_WORKING_DAYS_SUCCESS]: (state, { periodId, data }) => { + const periodsData = state.periodsData[0]; + let periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + periodData = periodsData[periodId] = { + ...periodData, + ...data, + cancelSource: null, + daysWorkedIsUpdated: true, + }; + state = { + ...state, + periodsData: [periodsData], + }; + return periodId in state.periodsById + ? updateStateAfterWorkingDaysChange(periodId, state) + : state; + }, + [ACTION_TYPE.WP_SET_WORKING_DAYS_ERROR]: (state, { periodId, message }) => { + console.error(message); + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + periodsData[periodId] = { + ...periodData, + cancelSource: null, + daysWorkedIsUpdated: false, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, [ACTION_TYPE.WP_TOGGLE_ONLY_FAILED_PAYMENTS]: (state, on) => { const filters = state.filters; on = on === null ? !filters.onlyFailedPayments : on; @@ -687,11 +805,11 @@ function updateStateAfterWorkingDaysChange(periodId, state) { const oldReasonsDisabled = periodsDisabledMap.get(periodId); let reasonsDisabled = periodData.daysWorked === periodData.daysPaid - ? addReasonDisabled( + ? addValueImmutable( oldReasonsDisabled, REASON_DISABLED.NO_DAYS_TO_PAY_FOR ) - : removeReasonDisabled( + : removeValueImmutable( oldReasonsDisabled, REASON_DISABLED.NO_DAYS_TO_PAY_FOR ); @@ -719,7 +837,8 @@ function updateSelectedPeriodsFlags(state) { const selectedCount = state.periodsSelected[0].size; const pageSize = state.pagination.pageSize; const totalCount = state.pagination.totalCount; - const maxSelectedOnPageCount = pageSize - state.periodsDisabled[0].size; + const maxSelectedOnPageCount = + Math.min(pageSize, totalCount) - state.periodsDisabled[0].size; if (totalCount > pageSize) { if (selectedCount === maxSelectedOnPageCount) { isSelectedPeriodsVisible = true; diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js index 23da36d..4b7b3a6 100644 --- a/src/store/selectors/workPeriods.js +++ b/src/store/selectors/workPeriods.js @@ -14,6 +14,14 @@ export const getWorkPeriodsStateSlice = (state) => state.workPeriods; */ export const getWorkPeriods = (state) => state.workPeriods.periods; +/** + * Returns an object with period ids as keys and alerts' arrays as values; + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getWorkPeriodsAlerts = (state) => state.workPeriods.periodsAlerts; + /** * Returns working periods' details. * diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index a352be0..755bb71 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -31,6 +31,46 @@ import { makeToastPaymentsError, } from "routes/WorkPeriods/utils/toasts"; import { RESOURCE_BOOKING_STATUS, WORK_PERIODS_PATH } from "constants/index.js"; +import { currencyFormatter } from "utils/formatters"; + +export const loadWorkPeriodAfterPaymentCancel = + (periodId, paymentId) => async (dispatch, getState) => { + let [periodsData] = selectors.getWorkPeriodsData(getState()); + periodsData[periodId]?.cancelSource?.cancel(); + const [promise, source] = services.fetchWorkPeriod(periodId); + dispatch(actions.setWorkPeriodDataPending(periodId, source)); + let periodData = null; + let userHandle = null; + let errorMessage = null; + try { + const data = await promise; + periodData = normalizePeriodData(data); + userHandle = data.userHandle; + } catch (error) { + if (!axios.isCancel(error)) { + errorMessage = error.toString(); + } + } + if (periodData) { + let amount = null; + for (let payment of periodData.payments) { + if (payment.id === paymentId) { + amount = currencyFormatter.format(payment.amount); + break; + } + } + dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); + makeToast( + `Payment ${amount} for ${userHandle} was marked as "cancelled"`, + "success" + ); + } else if (errorMessage) { + dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); + makeToast( + `Failed to load data for working period ${periodId}.\n` + errorMessage + ); + } + }; /** * Thunk that loads the specified working periods' page. If page number is not @@ -156,18 +196,13 @@ export const toggleWorkPeriodDetails = ); bilAccsPromise .then((data) => { - const periodsDetails = selectors.getWorkPeriodsDetails(getState()); - const periodDetails = periodsDetails[period.id]; - const billingAccountId = - (periodDetails && periodDetails.billingAccountId) || - period.billingAccountId; - const accounts = normalizeBillingAccounts(data, billingAccountId); - dispatch(actions.loadBillingAccountsSuccess(period.id, accounts)); + const accounts = normalizeBillingAccounts(data); + dispatch(actions.loadBillingAccountsSuccess(period, accounts)); }) .catch((error) => { if (!axios.isCancel(error)) { dispatch( - actions.loadBillingAccountsError(period.id, error.toString()) + actions.loadBillingAccountsError(period, error.toString()) ); } }); @@ -231,7 +266,7 @@ export const updateWorkPeriodWorkingDays = periodId, daysWorked ); - dispatch(actions.setWorkPeriodDataPending(periodId, source)); + dispatch(actions.setWorkPeriodWorkingDaysPending(periodId, source)); let periodData = null; let errorMessage = null; try { @@ -255,9 +290,9 @@ export const updateWorkPeriodWorkingDays = // and there will be a new request at the end of which the period's data // will be updated so again we don't need to update the state. if (periodData && periodData.daysWorked === currentDaysWorked) { - dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); + dispatch(actions.setWorkPeriodWorkingDaysSuccess(periodId, periodData)); } else if (errorMessage) { - dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); + dispatch(actions.setWorkPeriodWorkingDaysError(periodId, errorMessage)); } }; @@ -355,9 +390,9 @@ const processPaymentsSpecific = async (dispatch, getState) => { const resourcesSucceeded = []; const resourcesFailed = []; for (let result of results) { - let isFailed = "error" in result; - periodsToHighlight[result.workPeriodId] = isFailed; - if (isFailed) { + let error = result.error; + periodsToHighlight[result.workPeriodId] = error; + if (error) { resourcesFailed.push(result); } else { resourcesSucceeded.push(result); @@ -369,7 +404,6 @@ const processPaymentsSpecific = async (dispatch, getState) => { if (resourcesFailed.length) { makeToastPaymentsWarning({ resourcesSucceededCount: resourcesSucceeded.length, - resourcesFailed, resourcesFailedCount: resourcesFailed.length, }); } else { diff --git a/src/styles/typography.scss b/src/styles/typography.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/styles/variables.scss b/src/styles/variables.scss index e3a1501..8ce416a 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -1,5 +1,6 @@ @import "variables/screenSizes"; @import "variables/layout"; +@import "variables/typography"; @import "variables/colors"; @import "variables/forms"; @import "variables/popup"; diff --git a/src/styles/variables/_colors.scss b/src/styles/variables/_colors.scss index 5e0a4c6..1abfa76 100644 --- a/src/styles/variables/_colors.scss +++ b/src/styles/variables/_colors.scss @@ -6,6 +6,9 @@ $primary-dark-color: #137d60; // currently not used, can be changed $primary-dark-text-color: #137d60; // currently not used, can be changed $error-color: #e90c5a; +$error-text-color: #eb145f; +$warning-color: #ef476f; +$warning-text-color: #f05c7e; $text-color: #2a2a2a; $page-bg-color: #f4f5f6; diff --git a/src/styles/variables/_typography.scss b/src/styles/variables/_typography.scss new file mode 100644 index 0000000..2edb922 --- /dev/null +++ b/src/styles/variables/_typography.scss @@ -0,0 +1,2 @@ +$font-size-px: 14px; +$line-height-px: 22px; diff --git a/src/utils/hooks.js b/src/utils/hooks.js index ccb7b6b..ba6a1d2 100644 --- a/src/utils/hooks.js +++ b/src/utils/hooks.js @@ -3,26 +3,30 @@ import { useEffect, useRef } from "react"; /** * By "click" it is implied "mousedown" or "touchstart" * - * @param {Object} ref element reference obtained with useRef + * @param {Object} element HTML element * @param {function} listener function with stable identity * that will be executed on click outside * @param {Array} deps dependencies * when click happens outside the element referred by ref */ -export const useClickOutside = (ref, listener, deps) => { +export const useClickOutside = (element, listener, deps) => { useEffect(() => { - const onClick = (event) => { - let elem = ref.current; - if (elem && !elem.contains(event.target)) { - listener(); - } - }; - document.addEventListener("click", onClick); + let onClick = null; + if (element && listener) { + onClick = (event) => { + if (!element.contains(event.target)) { + listener(); + } + }; + document.addEventListener("click", onClick); + } return () => { - document.removeEventListener("click", onClick); + if (onClick) { + document.removeEventListener("click", onClick); + } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [listener, ...deps]); + }, [element, listener, ...deps]); }; /** diff --git a/src/utils/misc.js b/src/utils/misc.js index 4737b7a..e6f6dcf 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -80,6 +80,11 @@ export function stopPropagation(event) { event.stopPropagation(); } +export function stopImmediatePropagation(event) { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); +} + /** * This function takes keys referring to truthy values in `newOptions` * and adds them to `oldOptions` returning a new object. diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index d40dd12..1540a86 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -1,5 +1,6 @@ import moment from "moment"; import { + ALERT, API_CHALLENGE_PAYMENT_STATUS_MAP, API_PAYMENT_STATUS_MAP, DATE_FORMAT_API, @@ -9,6 +10,24 @@ import { URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; +/** + * Returns an array of working period's alert ids. + * + * @param {Object} period working period basic data object + * @param {Object} periodEndDate Moment object with current period end date + * @returns {Array} + */ +export function createPeriodAlerts(period, periodEndDate) { + const alerts = []; + if (!period.billingAccountId) { + alerts.push(ALERT.BA_NOT_ASSIGNED); + } + if (periodEndDate.isSameOrAfter(period.endDate)) { + alerts.push(ALERT.LAST_BOOKING_WEEK); + } + return alerts.length ? alerts : undefined; +} + /** * Checks for reasons the specified working period should be disabled for * payment processing. @@ -31,27 +50,27 @@ export function findReasonsDisabled(period) { return reasons.length ? reasons : undefined; } -export function addReasonDisabled(reasons, reason) { - if (!reasons) { - return [reason]; +export function addValueImmutable(items, value) { + if (!items) { + return [value]; } - if (reasons.indexOf(reason) < 0) { - reasons = [...reasons, reason]; + if (items.indexOf(value) < 0) { + items = [...items, value]; } - return reasons; + return items; } -export function removeReasonDisabled(reasons, reason) { - if (!reasons) { +export function removeValueImmutable(items, value) { + if (!items) { return undefined; } - let index = reasons.indexOf(reason); + let index = items.indexOf(value); if (index >= 0) { - let newReasons = [...reasons]; - newReasons.splice(index, 1); - return newReasons.length ? newReasons : undefined; + let newItems = [...items]; + newItems.splice(index, 1); + return newItems.length ? newItems : undefined; } - return reasons; + return items; } /** @@ -100,9 +119,11 @@ export function normalizePeriodItems(items) { billingAccountId: billingAccountId === null ? 0 : billingAccountId, teamName: "", userHandle: workPeriod.userHandle || "", + // resource booking period start date startDate: item.startDate ? moment(item.startDate).format(DATE_FORMAT_UI) : "", + // resource booking period end date endDate: item.endDate ? moment(item.endDate).format(DATE_FORMAT_UI) : "", weeklyRate: item.memberRate, data: normalizePeriodData(workPeriod), @@ -116,7 +137,9 @@ export function normalizeDetailsPeriodItems(items) { for (let item of items) { periods.push({ id: item.id, + // working period start date startDate: item.startDate ? moment(item.startDate).valueOf() : 0, + // working period end date endDate: item.endDate ? moment(item.endDate).valueOf() : 0, weeklyRate: item.memberRate, data: normalizePeriodData(item), @@ -149,9 +172,7 @@ export function normalizePeriodData(period) { if (payments) { let lastFailedPayment = null; for (let payment of payments) { - payment.status = - API_CHALLENGE_PAYMENT_STATUS_MAP[payment.status] || - PAYMENT_STATUS.UNDEFINED; + payment.status = normalizeChallengePaymentStatus(payment.status); if (payment.status === PAYMENT_STATUS.FAILED) { lastFailedPayment = payment; } @@ -162,6 +183,12 @@ export function normalizePeriodData(period) { return data; } +export function normalizeChallengePaymentStatus(paymentStatus) { + return ( + API_CHALLENGE_PAYMENT_STATUS_MAP[paymentStatus] || PAYMENT_STATUS.UNDEFINED + ); +} + export function normalizePaymentStatus(paymentStatus) { return API_PAYMENT_STATUS_MAP[paymentStatus]; } @@ -171,15 +198,12 @@ export function normalizePaymentStatus(paymentStatus) { * billing account. * * @param {Array} accounts array of billing accounts received for specific project - * @param {number} accountId resource booking's billing account id * @returns {Array} */ -export function normalizeBillingAccounts(accounts, accountId = -1) { +export function normalizeBillingAccounts(accounts) { const accs = []; - let hasSelectedAccount = false; for (let acc of accounts) { const value = +acc.tcBillingAccountId; - hasSelectedAccount = hasSelectedAccount || value === accountId; const endDate = acc.endDate ? moment(acc.endDate).format("DD MMM YYYY") : ""; @@ -188,9 +212,6 @@ export function normalizeBillingAccounts(accounts, accountId = -1) { label: `${acc.name} (${value})` + (endDate ? ` - ${endDate}` : ""), }); } - if (!hasSelectedAccount && accountId > 0) { - accs.unshift(createAssignedBillingAccountOption(accountId)); - } return accs; }