diff --git a/src/assets/images/icon-checkmark-circled.svg b/src/assets/images/icon-checkmark-circled.svg new file mode 100644 index 0000000..c551790 --- /dev/null +++ b/src/assets/images/icon-checkmark-circled.svg @@ -0,0 +1,5 @@ + + + + + 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/Content/styles.module.scss b/src/components/Content/styles.module.scss index 01ca520..dcb5511 100644 --- a/src/components/Content/styles.module.scss +++ b/src/components/Content/styles.module.scss @@ -4,7 +4,8 @@ padding: 0 10px; @include desktop { - flex: 1 1 auto; + flex: 1 1 0; padding: 0 35px; + min-width: 0; } } diff --git a/src/components/Icons/CheckmarkCircled/index.jsx b/src/components/Icons/CheckmarkCircled/index.jsx new file mode 100644 index 0000000..4b9fcdb --- /dev/null +++ b/src/components/Icons/CheckmarkCircled/index.jsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Icon from "../../../assets/images/icon-checkmark-circled.svg"; +import styles from "./styles.module.scss"; + +/** + * Displays an animated checkmark inside circle. After the specified timeout + * the checkmark is faded out and after fade transition ends the onTimeout + * is called. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {() => void} props.onTimeout + * @param {number} props.timeout timeout milliseconds + * @returns {JSX.Element} + */ +const CheckmarkCircled = ({ className, onTimeout, timeout = 2000 }) => { + const [isAnimated, setIsAnimated] = useState(false); + const [isTimedOut, setIsTimedOut] = useState(false); + + useEffect(() => { + setIsAnimated(true); + }, []); + + useEffect(() => { + setIsTimedOut(false); + let timeoutId = setTimeout(() => { + timeoutId = 0; + setIsTimedOut(true); + }, Math.max(timeout, /* total CSS animation duration */ 1200)); + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [timeout]); + + return ( + + + + ); +}; + +CheckmarkCircled.propTypes = { + className: PT.string, + onTimeout: PT.func.isRequired, + timeout: PT.number, +}; + +export default CheckmarkCircled; diff --git a/src/components/Icons/CheckmarkCircled/styles.module.scss b/src/components/Icons/CheckmarkCircled/styles.module.scss new file mode 100644 index 0000000..108fac3 --- /dev/null +++ b/src/components/Icons/CheckmarkCircled/styles.module.scss @@ -0,0 +1,79 @@ +@import "styles/variables"; + +.container { + display: inline-block; + width: 30px; + height: 30px; + opacity: 1; + transition: opacity 0.2s ease; +} + +.checkmark { + display: block; + width: auto; + height: 100%; + border-radius: 999px; + stroke-width: 2; + stroke: $primary-color; + stroke-miterlimit: 10; + box-shadow: inset 0px 0px 0px $primary-color; + animation-play-state: paused; + animation: /*checkmark-circled-fill 0.4s ease-in-out 0.4s forwards,*/ checkmark-circled-scale + 0.3s ease-in-out 0.9s both; + + :global(.checkmark__circle) { + stroke-dasharray: 166; + stroke-dashoffset: 166; + stroke-width: 2; + stroke-miterlimit: 10; + stroke: $primary-color; + fill: rgba(255, 255, 255, 0); + animation-play-state: paused; + animation: checkmark-circled-stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) + forwards; + } + + :global(.checkmark__check) { + transform-origin: 50% 50%; + stroke-dasharray: 48; + stroke-dashoffset: 48; + animation-play-state: paused; + animation: checkmark-circled-stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s + forwards; + } +} + +.animated { + animation-play-state: running; + + :global(.checkmark__circle), + :global(.checkmark__check) { + animation-play-state: running; + } +} + +.fadeOut { + opacity: 0; +} + +@keyframes checkmark-circled-stroke { + 100% { + stroke-dashoffset: 0; + } +} + +@keyframes checkmark-circled-scale { + 0%, + 100% { + transform: none; + } + 50% { + transform: scale3d(1.1, 1.1, 1); + } +} + +@keyframes checkmark-circled-fill { + 100% { + box-shadow: inset 0px 0px 0px 10px $primary-color; + } +} 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/JobName/index.jsx b/src/components/JobName/index.jsx new file mode 100644 index 0000000..e7236bf --- /dev/null +++ b/src/components/JobName/index.jsx @@ -0,0 +1,30 @@ +import React, { memo, useContext, useEffect } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import { JobNameContext } from "components/JobNameProvider"; +import { JOB_NAME_LOADING } from "constants/workPeriods"; +import styles from "./styles.module.scss"; + +const JobName = ({ className, jobId }) => { + const [getName, fetchName] = useContext(JobNameContext); + const [jobName, error] = getName(jobId); + + useEffect(() => { + fetchName(jobId); + }, [fetchName, jobId]); + + return ( + + {jobName || JOB_NAME_LOADING} + + ); +}; + +JobName.propTypes = { + className: PT.string, + jobId: PT.oneOfType([PT.number, PT.string]).isRequired, +}; + +export default memo(JobName); diff --git a/src/components/JobName/styles.module.scss b/src/components/JobName/styles.module.scss new file mode 100644 index 0000000..001d597 --- /dev/null +++ b/src/components/JobName/styles.module.scss @@ -0,0 +1,7 @@ +.container { + display: inline; +} + +.error { + color: #e90c5a; +} diff --git a/src/components/JobNameProvider/index.jsx b/src/components/JobNameProvider/index.jsx new file mode 100644 index 0000000..485c09a --- /dev/null +++ b/src/components/JobNameProvider/index.jsx @@ -0,0 +1,66 @@ +import React, { createContext, useCallback, useState } from "react"; +import PT from "prop-types"; +import { fetchJob } from "services/workPeriods"; +import { increment } from "utils/misc"; +import { + JOB_NAME_ERROR, + JOB_NAME_LOADING, + JOB_NAME_NONE, +} from "constants/workPeriods"; + +const names = {}; +const errors = {}; +const promises = {}; + +/** + * Returns a tuple containing job name and possibly an error. + * + * @param {number|string} id job id + * @returns {Array} + */ +const getName = (id) => (id ? [names[id], errors[id]] : [JOB_NAME_NONE, null]); + +export const JobNameContext = createContext([ + getName, + (id) => { + `${id}`; + }, +]); + +const JobNameProvider = ({ children }) => { + const [, setCount] = useState(Number.MIN_SAFE_INTEGER); + + const fetchName = useCallback((id) => { + if (!id || ((id in names || id in promises) && !(id in errors))) { + return; + } + names[id] = JOB_NAME_LOADING; + delete errors[id]; + setCount(increment); + const [promise] = fetchJob(id); + promises[id] = promise + .then((data) => { + names[id] = data.title; + }) + .catch((error) => { + names[id] = JOB_NAME_ERROR; + errors[id] = error; + }) + .finally(() => { + delete promises[id]; + setCount(increment); + }); + }, []); + + return ( + + {children} + + ); +}; + +JobNameProvider.propTypes = { + children: PT.node, +}; + +export default JobNameProvider; 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 dd22d5b..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,15 +39,21 @@ const Popup = ({ ], }); + useClickOutside(popperElement, onClickOutside, []); + return (
{children} -
+
); }; @@ -48,6 +61,8 @@ const Popup = ({ Popup.propTypes = { children: PT.node, className: PT.string, + onClickOutside: PT.func, + placement: PT.string, referenceElement: PT.object.isRequired, strategy: PT.oneOf(["absolute", "fixed"]), }; diff --git a/src/components/Popup/styles.module.scss b/src/components/Popup/styles.module.scss index 6a800ea..72f1e2b 100644 --- a/src/components/Popup/styles.module.scss +++ b/src/components/Popup/styles.module.scss @@ -7,7 +7,7 @@ background: #fff; box-shadow: $popover-box-shadow; - :global(.popup-arrow) { + .popupArrow { display: none; } } diff --git a/src/components/ProjectName/index.jsx b/src/components/ProjectName/index.jsx index 33be6fd..2550af0 100644 --- a/src/components/ProjectName/index.jsx +++ b/src/components/ProjectName/index.jsx @@ -1,7 +1,7 @@ import React, { memo, useContext, useEffect } from "react"; import PT from "prop-types"; import cn from "classnames"; -import { ProjectNameContext } from "components/ProjectNameContextProvider"; +import { ProjectNameContext } from "components/ProjectNameProvider"; import styles from "./styles.module.scss"; const ProjectName = ({ className, projectId }) => { diff --git a/src/components/ProjectName/styles.module.scss b/src/components/ProjectName/styles.module.scss index 5900b9c..b61d947 100644 --- a/src/components/ProjectName/styles.module.scss +++ b/src/components/ProjectName/styles.module.scss @@ -1,7 +1,7 @@ @import "styles/mixins"; .container { - display: block; + display: inline-block; max-width: 20em; overflow: hidden; text-overflow: ellipsis; diff --git a/src/components/ProjectNameContextProvider/index.jsx b/src/components/ProjectNameProvider/index.jsx similarity index 100% rename from src/components/ProjectNameContextProvider/index.jsx rename to src/components/ProjectNameProvider/index.jsx diff --git a/src/components/SearchHandleField/index.jsx b/src/components/SearchHandleField/index.jsx index b4e9c5d..cc6c3e2 100644 --- a/src/components/SearchHandleField/index.jsx +++ b/src/components/SearchHandleField/index.jsx @@ -102,6 +102,7 @@ const SearchHandleField = ({ setIsMenuFocused(false); setIsMenuOpen(false); setIsLoading(false); + setInputValue(option.value); onChange(option.value); } } else if (action === "clear") { @@ -109,6 +110,7 @@ const SearchHandleField = ({ setIsMenuFocused(false); setIsMenuOpen(false); setIsLoading(false); + setInputValue(""); onChange(""); } }; @@ -128,7 +130,7 @@ const SearchHandleField = ({ const onKeyDown = (event) => { 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/SelectField/styles.module.scss b/src/components/SelectField/styles.module.scss index 3d578f5..9b693e3 100644 --- a/src/components/SelectField/styles.module.scss +++ b/src/components/SelectField/styles.module.scss @@ -104,14 +104,16 @@ .medium { :global(.custom__value-container) { - margin-top: 4px; + margin-top: 2px; + margin-bottom: 2px; padding: 6px 15px; } } .small { :global(.custom__value-container) { - margin-top: 2px; + margin-top: 1px; + margin-bottom: 1px; padding: 2px 7px 2px 13px; } 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/index.jsx b/src/components/Tooltip/index.jsx new file mode 100644 index 0000000..737128c --- /dev/null +++ b/src/components/Tooltip/index.jsx @@ -0,0 +1,128 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { usePopper } from "react-popper"; +import PT from "prop-types"; +import cn from "classnames"; +import compStyles from "./styles.module.scss"; + +/** + * Displays a tooltip + * + * @param {Object} props component properties + * @param {any} props.children tooltip target + * @param {string} [props.className] class name to be added to root element + * @param {any} props.content tooltip content + * @param {number} [props.delay] postpone showing the tooltip after this delay + * @param {boolean} [props.isDisabled] whether the tooltip is disabled + * @param {import('@popperjs/core').Placement} [props.placement] tooltip's + * preferred placement as defined in PopperJS documentation + * @param {'absolute'|'fixed'} [props.strategy] tooltip positioning strategy + * as defined in PopperJS documentation + * @param {string} [props.targetClassName] class name to be added to element + * wrapping around component's children + * @param {string} [props.tooltipClassName] class name to be added to tooltip + * element itself + * @returns {JSX.Element} + */ +const Tooltip = ({ + children, + className, + content, + delay = 150, + isDisabled = false, + placement = "top", + strategy = "absolute", + targetClassName, + tooltipClassName, +}) => { + const containerRef = useRef(null); + const timeoutIdRef = useRef(0); + const [isTooltipShown, setIsTooltipShown] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const { styles, attributes, update } = usePopper( + referenceElement, + popperElement, + { + placement, + strategy, + modifiers: [ + { name: "arrow", options: { element: arrowElement, padding: 10 } }, + { name: "offset", options: { offset: [0, 10] } }, + { name: "preventOverflow", options: { padding: 15 } }, + ], + } + ); + + const onMouseEnter = useCallback(() => { + timeoutIdRef.current = window.setTimeout(() => { + timeoutIdRef.current = 0; + setIsTooltipShown(true); + }, delay); + }, [delay]); + + const onMouseLeave = useCallback(() => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + setIsTooltipShown(false); + }, []); + + useEffect(() => { + let observer = null; + if (isTooltipShown && popperElement && update) { + observer = new ResizeObserver(update); + observer.observe(popperElement); + } + return () => { + if (observer) { + observer.unobserve(popperElement); + } + }; + }, [isTooltipShown, popperElement, update]); + + return ( +
+ + {children} + + {!isDisabled && isTooltipShown && ( +
+ {content} +
+
+ )} +
+ ); +}; + +Tooltip.propTypes = { + children: PT.node, + className: PT.string, + content: PT.node, + delay: PT.number, + isDisabled: PT.bool, + placement: PT.string, + strategy: PT.oneOf(["absolute", "fixed"]), + targetClassName: PT.string, + tooltipClassName: PT.string, +}; + +export default Tooltip; diff --git a/src/components/Tooltip/styles.module.scss b/src/components/Tooltip/styles.module.scss new file mode 100644 index 0000000..c0d1d6e --- /dev/null +++ b/src/components/Tooltip/styles.module.scss @@ -0,0 +1,46 @@ +.container { + position: relative; + display: inline-flex; + align-items: baseline; +} + +.target { + display: inline-flex; + align-items: baseline; +} + +.tooltip { + z-index: 8; + border-radius: 8px; + padding: 10px 15px; + line-height: 22px; + 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%; + border: 10px solid transparent; + border-bottom: none; + border-top-color: #fff; + width: 0; + height: 0; + } +} diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 89d5db4..f79f417 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -1,19 +1,23 @@ // @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"; import * as SORT_BY from "./workPeriods/sortBy"; import * as SORT_ORDER from "./workPeriods/sortOrder"; 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, SORT_BY, SORT_ORDER, PAYMENT_STATUS, + REASON_DISABLED, }; // resource bookings API url @@ -22,8 +26,10 @@ export const JOBS_API_URL = `${API.V5}/jobs`; export const PAYMENTS_API_URL = `${API.V5}/work-period-payments`; export const PROJECTS_API_URL = `${API.V5}/projects`; export const WORK_PERIODS_API_URL = `${API.V5}/work-periods`; +export const TAAS_TEAM_API_URL = `${API.V5}/taas-teams`; export const DATE_FORMAT_API = "YYYY-MM-DD"; +export const DATE_FORMAT_ISO = "YYYY-MM-DD"; export const DATE_FORMAT_UI = "MMM DD, YYYY"; // Field names that are required to be retrieved for display, filtering and sorting. @@ -47,11 +53,13 @@ export const API_REQUIRED_FIELDS = [ "workPeriods.daysPaid", "workPeriods.payments.amount", "workPeriods.payments.challengeId", + "workPeriods.payments.createdAt", "workPeriods.payments.days", "workPeriods.payments.id", "workPeriods.payments.memberRate", "workPeriods.payments.status", "workPeriods.payments.statusDetails", + "workPeriods.payments.workPeriodId", ]; // Valid parameter names for requests. @@ -135,3 +143,15 @@ export const JOB_NAME_ERROR = ""; export const BILLING_ACCOUNTS_LOADING = "Loading..."; export const BILLING_ACCOUNTS_NONE = ""; export const BILLING_ACCOUNTS_ERROR = ""; + +export const REASON_DISABLED_MESSAGE_MAP = { + [REASON_DISABLED.NO_BILLING_ACCOUNT]: + "Billing Account is not set for the Resource Booking", + [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/constants/workPeriods/reasonDisabled.js b/src/constants/workPeriods/reasonDisabled.js new file mode 100644 index 0000000..bd98ced --- /dev/null +++ b/src/constants/workPeriods/reasonDisabled.js @@ -0,0 +1,3 @@ +export const NO_BILLING_ACCOUNT = "NO_BILLING_ACCOUNT"; +export const NO_DAYS_TO_PAY_FOR = "NO_DAYS_TO_PAY_FOR"; +export const NO_MEMBER_RATE = "NO_MEMBER_RATE"; diff --git a/src/decls/svg.d.ts b/src/decls/svg.d.ts index 9348424..a180524 100644 --- a/src/decls/svg.d.ts +++ b/src/decls/svg.d.ts @@ -1,4 +1,6 @@ -declare module '*.svg' { - const value: string; +declare module "*.svg" { + const value: import("react").FunctionComponent< + React.SVGAttributes + >; export default value; } diff --git a/src/routes/WorkPeriods/components/PaymentCancel/index.jsx b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx new file mode 100644 index 0000000..a5115c1 --- /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/PaymentErrorDetails/styles.module.scss b/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss index a1f4bf1..bbbed10 100644 --- a/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss @@ -2,7 +2,6 @@ .container { display: block; - max-width: 480px; text-align: left; } 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 e34978b..457bd20 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -6,6 +6,7 @@ import debounce from "lodash/debounce"; import Button from "components/Button"; import Toggle from "components/Toggle"; import SelectField from "components/SelectField"; +import JobName from "components/JobName"; import PeriodsHistory from "../PeriodsHistory"; import IconComputer from "../../../../assets/images/icon-computer.svg"; import { @@ -13,9 +14,9 @@ import { setBillingAccount, setDetailsHidePastPeriods, } from "store/actions/workPeriods"; -import styles from "./styles.module.scss"; import { updateWorkPeriodBillingAccount } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; +import styles from "./styles.module.scss"; /** * Displays working period details. @@ -25,16 +26,19 @@ import { useUpdateEffect } from "utils/hooks"; * @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, - jobName, - jobNameError, - billingAccountId, billingAccounts, billingAccountsError, billingAccountsIsDisabled, @@ -85,7 +89,7 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { )} > {periodsIsLoading ? ( - +
Loading...
) : ( @@ -95,21 +99,15 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => {
Job Name
-
- {jobName} -
+
-
+
Billing Account
{
- +
History @@ -145,6 +143,8 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => {
@@ -159,12 +159,6 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { PeriodDetails.propTypes = { className: PT.string, details: PT.shape({ - periodId: PT.string.isRequired, - rbId: 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, @@ -180,6 +174,14 @@ 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, + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, + }).isRequired, }; export default memo(PeriodDetails); diff --git a/src/routes/WorkPeriods/components/PeriodDetails/styles.module.scss b/src/routes/WorkPeriods/components/PeriodDetails/styles.module.scss index 1553b9a..a3181b5 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodDetails/styles.module.scss @@ -69,10 +69,14 @@ color: #e90c5a; } -.billingAccountSection { +.billingAccountsSection { margin-top: 13px; } +.billingAccountsSelect { + min-width: 368px; +} + .billingAccountsError { color: #e90c5a; } diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 7e68b21..9f890a0 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -1,18 +1,25 @@ -import React, { memo, useCallback } from "react"; +import React, { memo, useCallback, useMemo } from "react"; import { useDispatch } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; import debounce from "lodash/debounce"; import Checkbox from "components/Checkbox"; -import IntegerField from "components/IntegerField"; +import JobName from "components/JobName"; import ProjectName from "components/ProjectName"; +import Tooltip from "components/Tooltip"; import PaymentError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; +import PeriodWorkingDays from "../PeriodWorkingDays"; import PeriodDetails from "../PeriodDetails"; -import { PAYMENT_STATUS } from "constants/workPeriods"; +import ProcessingError from "../ProcessingError"; +import { + PAYMENT_STATUS, + REASON_DISABLED_MESSAGE_MAP, +} from "constants/workPeriods"; import { setWorkPeriodWorkingDays, + toggleWorkingDaysUpdated, toggleWorkPeriod, } from "store/actions/workPeriods"; import { @@ -20,29 +27,38 @@ import { updateWorkPeriodWorkingDays, } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; -import { formatUserHandleLink, formatWeeklyRate } from "utils/formatters"; +import { + formatDate, + 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(); @@ -57,6 +73,10 @@ const PeriodItem = ({ dispatch(toggleWorkPeriodDetails(item)); }, [dispatch, item]); + const onWorkingDaysUpdateHintTimeout = useCallback(() => { + dispatch(toggleWorkingDaysUpdated(item.id, false)); + }, [dispatch, item.id]); + const onWorkingDaysChange = useCallback( (daysWorked) => { dispatch(setWorkPeriodWorkingDays(item.id, daysWorked)); @@ -80,28 +100,73 @@ const PeriodItem = ({ updateWorkingDays(data.daysWorked); }, [data.daysWorked]); + const jobName = useMemo( + () => ( + + Job Name:  + + + ), + [item.jobId] + ); + + const projectId = useMemo( + () => ( + + Project ID:  + {item.projectId} + + ), + [item.projectId] + ); + + const reasonsDisabledElement = useMemo( + () => ( +
+ {formatReasonsDisabled(reasonsDisabled)} +
+ ), + [reasonsDisabled] + ); + return ( <> - + + + + + {reasonFailed && ( + + )} + - + {item.userHandle} - + - + + + + + {formatDate(item.bookingStart)} + {formatDate(item.bookingEnd)} + + - {item.startDate} - {item.endDate} {formatWeeklyRate(item.weeklyRate)} @@ -141,57 +211,81 @@ 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, 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, - startDate: PT.string.isRequired, - endDate: PT.string.isRequired, + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, weeklyRate: PT.number, }).isRequired, + alerts: PT.arrayOf(PT.string), data: PT.shape({ - daysWorked: PT.number.isRequired, daysPaid: PT.number.isRequired, + daysWorked: PT.number.isRequired, paymentErrorLast: PT.object, payments: PT.array, paymentStatus: PT.string.isRequired, 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, @@ -202,6 +296,8 @@ PeriodItem.propTypes = { periods: PT.array.isRequired, periodsIsLoading: PT.bool.isRequired, }), + reasonFailed: PT.object, + reasonsDisabled: PT.arrayOf(PT.string), }; export default memo(PeriodItem); diff --git a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss index e637860..576ed4f 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss @@ -1,10 +1,10 @@ @import "styles/mixins"; @import "styles/variables"; -tr.container { +.container { > td { - padding-left: 17px; - padding-right: 17px; + padding-left: 15px; + padding-right: 15px; background: #fff; } @@ -40,9 +40,35 @@ tr.container { } } +.periodDetails { + + .container.hasDetails { + > td { + &.toggle { + padding-top: 12px; + } + + &.daysWorked { + padding-top: 5px; + } + } + } +} + td.toggle { - padding: 12px 20px 12px 15px; + padding: 12px 18px 12px 15px; line-height: 15px; + white-space: nowrap; +} + +.selectionCheckbox { + display: inline-block; +} + +.processingError { + display: inline-block; + position: absolute; + margin-left: 10px; + width: 15px; } .userHandle { @@ -50,14 +76,14 @@ td.toggle { @include roboto-bold; color: #0d61bf; } +} - span { - display: block; - max-width: 20em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +.userHandleContainer { + display: inline-block; + max-width: 20em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } td.teamName { @@ -67,6 +93,8 @@ td.teamName { td.startDate, td.endDate { + padding-left: 10px; + padding-right: 10px; white-space: nowrap; } @@ -91,6 +119,13 @@ td.daysWorked { padding: 5px 10px; } -.daysWorkedControl { - width: 100px; +.tooltipContent { + white-space: nowrap; + font-weight: 400; +} + +.tooltipLabel { + margin-right: 5px; + white-space: nowrap; + font-weight: 500; } diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index 6394127..f1b0e76 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -2,13 +2,16 @@ import React from "react"; import { useSelector } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; -import ProjectNameContextProvider from "components/ProjectNameContextProvider"; +import JobNameProvider from "components/JobNameProvider"; +import ProjectNameProvider from "components/ProjectNameProvider"; import PeriodItem from "../PeriodItem"; import PeriodListHead from "../PeriodListHead"; import { getWorkPeriods, + getWorkPeriodsAlerts, getWorkPeriodsData, getWorkPeriodsDetails, + getWorkPeriodsDisabled, getWorkPeriodsFailed, getWorkPeriodsIsProcessingPayments, getWorkPeriodsSelected, @@ -24,38 +27,50 @@ 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); const periodsFailed = useSelector(getWorkPeriodsFailed); - const periodsSelected = useSelector(getWorkPeriodsSelected); + const [periodsSelectedSet] = useSelector(getWorkPeriodsSelected); const isProcessingPayments = useSelector(getWorkPeriodsIsProcessingPayments); return ( - -
    - - - - - - - - - {periods.map((period) => ( - - ))} - -
    -
    -
    + + +
    + + + + + + + + + {periods.map((period) => ( + + ))} + +
    +
    +
    +
    ); }; diff --git a/src/routes/WorkPeriods/components/PeriodList/styles.module.scss b/src/routes/WorkPeriods/components/PeriodList/styles.module.scss index 9bb7222..2e8d7aa 100644 --- a/src/routes/WorkPeriods/components/PeriodList/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodList/styles.module.scss @@ -3,6 +3,13 @@ .container { position: relative; padding: 0 20px 0 15px; + width: 100%; + overflow-x: auto; + overflow-y: visible; + + &.hasItems { + min-height: 348px; + } } .table { diff --git a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx index 936765a..ee70844 100644 --- a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx @@ -53,7 +53,7 @@ const PeriodListHead = () => { {HEAD_CELLS.map(({ id, className, label, disableSort }) => ( -
    +
    {label} {!disableSort && ( { const HEAD_CELLS = [ { label: "Topcoder Handle", id: SORT_BY.USER_HANDLE }, { label: "Team Name", id: SORT_BY.TEAM_NAME, disableSort: true }, - { label: "Start Date", id: SORT_BY.START_DATE }, - { label: "End Date", id: SORT_BY.END_DATE }, + { 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/PeriodListHead/styles.module.scss b/src/routes/WorkPeriods/components/PeriodListHead/styles.module.scss index 68decd6..88f3f15 100644 --- a/src/routes/WorkPeriods/components/PeriodListHead/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodListHead/styles.module.scss @@ -1,7 +1,7 @@ @import "styles/mixins"; .container { - th { + > th { text-align: left; background: #f4f4f4; @@ -34,7 +34,7 @@ &:last-child { .colHead { - padding: 12px 10px; + padding: 12px 10px 12px 50px; &::before { right: -20px; @@ -43,8 +43,14 @@ } } - :global(.weeklyRate), - :global(.totalPaid) { + .startDate, + .endDate { + padding-left: 10px; + padding-right: 10px; + } + + .weeklyRate, + .totalPaid { justify-content: flex-end; } } @@ -54,7 +60,7 @@ display: flex; justify-content: flex-start; align-items: center; - padding: 12px 17px; + padding: 12px 15px; height: 40px; } diff --git a/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx b/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx new file mode 100644 index 0000000..d7eebf8 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx @@ -0,0 +1,141 @@ +import React, { useMemo } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Tooltip from "components/Tooltip"; +import IconCheckmarkCircled from "components/Icons/CheckmarkCircled"; +import { formatDate } from "utils/formatters"; +import { stopPropagation } from "utils/misc"; +import styles from "./styles.module.scss"; + +/** + * Displays working days input field with an icon hinting about the update. + * + * @param {Object} props component properties + * @param {string} props.bookingStart resource booking start date + * @param {string} props.bookingEnd resource booking end date + * @param {string} [props.className] class name to be added to root element + * @param {string} props.controlName working days input control name + * @param {Object} props.data working period data object + * @param {boolean} props.isDisabled whether the input field should be disabled + * @param {(v: number) => void} props.onWorkingDaysChange function called when + * working days change + * @param {() => void} props.onWorkingDaysUpdateHintTimeout function called when + * update hint icon has finished its animation + * @param {number} [props.updateHintTimeout] timeout in milliseconds for update + * hint icon + * @returns {JSX.Element} + */ +const PeriodWorkingDays = ({ + bookingStart, + bookingEnd, + className, + controlName, + data: { daysPaid, daysWorked, daysWorkedMax, daysWorkedIsUpdated }, + isDisabled, + onWorkingDaysChange, + onWorkingDaysUpdateHintTimeout, + updateHintTimeout = 2000, +}) => { + const isBtnMinusDisabled = + daysWorked === 0 || (daysWorked > 0 && daysWorked <= daysPaid); + const isBtnPlusDisabled = daysWorked < 5 && daysWorked >= daysWorkedMax; + const decreaseDaysWorkedMessage = useMemo( + () => `Cannot decrease "Working Days" below the number of days already + paid for: ${daysPaid}`, + [daysPaid] + ); + const increaseDaysWorkedMessage = useMemo( + () => `Cannot increase "Working Days" because the Resource Booking period + is between ${formatDate(bookingStart)} and ${formatDate(bookingEnd)}`, + [bookingStart, bookingEnd] + ); + + return ( +
    + + {daysWorkedIsUpdated && ( + + )} + +
    + + +
    +
    + ); +}; + +PeriodWorkingDays.propTypes = { + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, + className: PT.string, + controlName: PT.string.isRequired, + data: PT.shape({ + daysPaid: PT.number.isRequired, + daysWorked: PT.number.isRequired, + daysWorkedMax: PT.number.isRequired, + daysWorkedIsUpdated: PT.bool.isRequired, + }).isRequired, + isDisabled: PT.bool.isRequired, + onWorkingDaysChange: PT.func.isRequired, + onWorkingDaysUpdateHintTimeout: PT.func.isRequired, + updateHintTimeout: PT.number, +}; + +export default PeriodWorkingDays; diff --git a/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss b/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss new file mode 100644 index 0000000..b27f3bd --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss @@ -0,0 +1,144 @@ +@import "styles/variables"; + +.container { + display: flex; + align-items: baseline; +} + +.iconPlaceholder { + align-self: center; + margin-right: 10px; + width: 30px; + height: 30px; +} + +.checkmarkIcon { + display: block; + width: 100%; + height: 100%; +} + +.daysWorkedControl { + position: relative; + display: inline-flex; + align-items: center; + border: 1px solid $control-border-color; + border-radius: 6px; + width: 100px; + overflow: hidden; +} + +input.input { + flex: 1 1 0; + margin: 0; + border: none !important; + padding: 3px 0; + height: 28px; + line-height: 22px; + background: #fff; + outline: none !important; + box-shadow: none !important; + text-align: center; + + &:disabled { + border-color: $control-disabled-border-color; + background-color: $control-disabled-bg-color; + color: $control-disabled-text-color; + cursor: not-allowed; + + ~ .btnMinus, + ~ .btnPlus { + cursor: not-allowed; + } + } +} + +.tooltip { + max-width: 400px; +} + +.tooltipTarget { + display: block; + width: 100%; + height: 100%; + + &.notAllowed { + cursor: not-allowed; + } +} + +.btnMinus, +.btnPlus { + position: absolute; + top: 0; + bottom: 0; + margin: auto; + width: 30px; + height: 30px; + + button { + display: block; + position: relative; + border: none; + padding: 0; + width: 100%; + height: 100%; + background: transparent; + outline: none !important; + cursor: pointer; + + &:disabled { + background: #ddd; + opacity: 1; + } + } +} + +.btnMinus { + left: 0; + + button { + &::before { + content: ""; + display: block; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: auto; + width: 8px; + height: 1px; + background: #7f7f7f; + } + } +} + +.btnPlus { + right: 0; + + button { + &::before, + &::after { + content: ""; + display: block; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: auto; + background: #7f7f7f; + } + + &::before { + width: 9px; + height: 1px; + } + + &::after { + width: 1px; + height: 9px; + } + } +} diff --git a/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx index c1cf309..caeaed8 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx @@ -15,7 +15,13 @@ import styles from "./styles.module.scss"; * @param {Object} props component properties * @returns {JSX.Element} */ -const PeriodsHistory = ({ className, isDisabled, periods }) => { +const PeriodsHistory = ({ + bookingStart, + bookingEnd, + className, + isDisabled, + periods, +}) => { const [periodsData] = useSelector(getWorkPeriodsData); const [startDate] = useSelector(getWorkPeriodsDateRange); return ( @@ -25,6 +31,8 @@ const PeriodsHistory = ({ className, isDisabled, periods }) => { {periods.map((period) => ( { }; PeriodsHistory.propTypes = { + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, className: PT.string, isDisabled: PT.bool.isRequired, periods: PT.arrayOf(PT.object), diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx index f38e4dc..235047a 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx @@ -3,16 +3,22 @@ import { useDispatch } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; import debounce from "lodash/debounce"; -import moment from "moment"; -import IntegerField from "components/IntegerField"; -import PaymentError from "../PaymentError"; +import ProcessingError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; +import PeriodWorkingDays from "../PeriodWorkingDays"; import { PAYMENT_STATUS } from "constants/workPeriods"; -import { setDetailsWorkingDays } from "store/actions/workPeriods"; +import { + setDetailsWorkingDays, + toggleWorkingDaysUpdated, +} from "store/actions/workPeriods"; import { updateWorkPeriodWorkingDays } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; -import { formatDateLabel, formatDateRange } from "utils/formatters"; +import { + formatDateLabel, + formatDateRange, + formatPlural, +} from "utils/formatters"; import styles from "./styles.module.scss"; /** @@ -21,12 +27,19 @@ import styles from "./styles.module.scss"; * @param {Object} props component properties * @returns {JSX.Element} */ -const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { +const PeriodsHistoryItem = ({ + bookingStart, + bookingEnd, + isDisabled, + item, + data, + currentStartDate, +}) => { const dispatch = useDispatch(); - const dateLabel = formatDateLabel(item.startDate, currentStartDate); + const dateLabel = formatDateLabel(item.start, currentStartDate); const daysWorked = data.daysWorked; - const isCurrent = moment(item.startDate).isSame(currentStartDate, "date"); + const isCurrent = item.start.isSame(currentStartDate, "date"); const onWorkingDaysChange = useCallback( (daysWorked) => { @@ -35,6 +48,10 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { [dispatch, item.id] ); + const onWorkingDaysUpdateHintTimeout = useCallback(() => { + dispatch(toggleWorkingDaysUpdated(item.id, false)); + }, [dispatch, item.id]); + const updateWorkingDays = useCallback( debounce( (daysWorked) => { @@ -49,9 +66,9 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { // Update working days on server if working days change. useUpdateEffect(() => { if (!isCurrent) { - updateWorkingDays(data.daysWorked); + updateWorkingDays(daysWorked); } - }, [data.daysWorked, isCurrent]); + }, [daysWorked, isCurrent]); return ( { })} > - {formatDateRange(item.startDate, item.endDate)} + {formatDateRange(item.start, item.end)} {dateLabel} {data.paymentErrorLast && ( - { {data.paymentStatus === PAYMENT_STATUS.COMPLETED ? ( - `${daysWorked} ${daysWorked === 1 ? "Day" : "Days"}` + + {formatPlural(daysWorked, "Day")} + ) : ( - )} @@ -104,18 +124,20 @@ PeriodsHistoryItem.propTypes = { isDisabled: PT.bool.isRequired, item: PT.shape({ id: PT.string.isRequired, - startDate: PT.oneOfType([PT.string, PT.number]).isRequired, - endDate: PT.oneOfType([PT.string, PT.number]).isRequired, + start: PT.object.isRequired, + end: PT.object.isRequired, weeklyRate: PT.number, }).isRequired, data: PT.shape({ - daysWorked: PT.number.isRequired, daysPaid: PT.number.isRequired, + daysWorked: PT.number.isRequired, paymentErrorLast: PT.object, payments: PT.array, paymentStatus: PT.string.isRequired, paymentTotal: PT.number.isRequired, }).isRequired, + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, currentStartDate: PT.oneOfType([PT.string, PT.number, PT.object]).isRequired, }; diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss index d85f175..1db84fa 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss @@ -48,6 +48,10 @@ padding: 4px 10px; } +.daysWorkedLabel { + margin-left: 40px; +} + .daysWorkedControl { display: block; width: 100px; diff --git a/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx index f937827..3d27a53 100644 --- a/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx @@ -6,10 +6,12 @@ import { getWorkPeriodsIsSelectedAll, getWorkPeriodsIsSelectedVisible, getWorkPeriodsPageSize, + getWorkPeriodsSelectedCount, getWorkPeriodsTotalCount, } from "store/selectors/workPeriods"; import { toggleWorkingPeriodsAll } from "store/actions/workPeriods"; import styles from "./styles.module.scss"; +import { formatIsAre, formatPlural } from "utils/formatters"; /** * Displays messages about the number of selected periods and selection controls. @@ -21,6 +23,7 @@ import styles from "./styles.module.scss"; const PeriodsSelectionMessage = ({ className }) => { const isSelectedAll = useSelector(getWorkPeriodsIsSelectedAll); const isSelectedVisible = useSelector(getWorkPeriodsIsSelectedVisible); + const selectedCount = useSelector(getWorkPeriodsSelectedCount); const pageSize = useSelector(getWorkPeriodsPageSize); const totalCount = useSelector(getWorkPeriodsTotalCount); const dispatch = useDispatch(); @@ -34,15 +37,22 @@ const PeriodsSelectionMessage = ({ className }) => { {isSelectedVisible && totalCount > pageSize && ( {isSelectedAll - ? `All ${totalCount} Records are selected. ` - : `All ${pageSize} Records on this page are selected. `} + ? `All ${formatPlural(totalCount, "record")} ${formatIsAre( + totalCount + )} selected. ` + : `${selectedCount < pageSize ? "" : "All"} ${formatPlural( + selectedCount, + "record" + )} on this page ${formatIsAre(selectedCount)} selected. `} - {isSelectedAll ? "Deselect" : `Select all ${totalCount} Records`} + {isSelectedAll + ? "Deselect" + : `Select all ${formatPlural(totalCount, "record")}`} )} diff --git a/src/routes/WorkPeriods/components/ProcessingError/index.jsx b/src/routes/WorkPeriods/components/ProcessingError/index.jsx new file mode 100644 index 0000000..ad3d2d1 --- /dev/null +++ b/src/routes/WorkPeriods/components/ProcessingError/index.jsx @@ -0,0 +1,34 @@ +import React from "react"; +import PT from "prop-types"; +import Popover from "components/Popover"; +import IconExclamationMark from "components/Icons/ExclamationMarkCircled"; +import styles from "./styles.module.scss"; + +/** + * Displays an error icon and error details popup. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {Object} [props.error] error object + * @param {'absolute'|'fixed'} [props.popupStrategy] popup positioning strategy + * @returns {JSX.Element} + */ +const ProcessingError = ({ className, error, popupStrategy = "absolute" }) => ( + + + +); + +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/ToastPaymentsError/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx index fd12b97..4812271 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import ToastMessage from "components/ToastrMessage"; +import { formatPlural } from "utils/formatters"; /** * Displays a toastr message with info about the number of resources payments @@ -12,7 +13,7 @@ import ToastMessage from "components/ToastrMessage"; const ToastPaymentsError = ({ resourceCount, remove }) => { return ( - Failed to schedule payments for {resourceCount} resources + Failed to schedule payment for {formatPlural(resourceCount, "resource")} ); }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx index 76357d0..a6216cf 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import ToastMessage from "components/ToastrMessage"; +import { formatPlural } from "utils/formatters"; import styles from "./styles.module.scss"; /** @@ -14,7 +15,7 @@ const ToastPaymentsProcessing = ({ resourceCount, remove }) => { return ( - Payment in progress for {resourceCount} resources + Payment in progress for {formatPlural(resourceCount, "resource")} ); }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx index bba3215..fe7ac0b 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import ToastMessage from "components/ToastrMessage"; +import { formatPlural } from "utils/formatters"; /** * Displays a toastr message with info about the number of resources payments @@ -12,7 +13,7 @@ import ToastMessage from "components/ToastrMessage"; const ToastPaymentsSuccess = ({ resourceCount, remove }) => { return ( - Payment scheduled for {resourceCount} resources + Payment scheduled for {formatPlural(resourceCount, "resource")} ); }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx index 43d4456..edf1307 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import ToastMessage from "components/ToastrMessage"; +import { formatPlural } from "utils/formatters"; import styles from "./styles.module.scss"; /** @@ -28,12 +29,14 @@ const ToastPaymentsWarning = ({
    - Payment scheduled for {resourcesSucceededCount} resources + Payment scheduled for{" "} + {formatPlural(resourcesSucceededCount, "resource")}
    - Failed to schedule payment for {resourcesFailedCount} resources + Failed to schedule payment for{" "} + {formatPlural(resourcesFailedCount, "resource")} {resourcesFailed && resourcesFailed.length ? ":" : ""}
    {resourcesFailed && resourcesFailed.length && ( 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/routes/WorkPeriods/index.jsx b/src/routes/WorkPeriods/index.jsx index 3e9649f..25d6a96 100644 --- a/src/routes/WorkPeriods/index.jsx +++ b/src/routes/WorkPeriods/index.jsx @@ -20,28 +20,25 @@ import styles from "./styles.module.scss"; * @returns {JSX.Element} */ const WorkPeriods = () => ( - + - +
    - +
    diff --git a/src/routes/WorkPeriods/styles.module.scss b/src/routes/WorkPeriods/styles.module.scss index 84a9490..ef2c3b6 100644 --- a/src/routes/WorkPeriods/styles.module.scss +++ b/src/routes/WorkPeriods/styles.module.scss @@ -1,31 +1,57 @@ -.container { -} - -.periodsBlock { -} +@import "styles/mixins"; .periodsHeader { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: center; - padding: 13px 13px 13px 32px; -} + padding: 13px 13px 13px 29px; -.periodsFooter { - display: flex; - justify-content: flex-end; - align-items: center; - padding: 13px 13px 13px 32px; + @include desktop { + flex-wrap: nowrap; + } } .periodCount { + margin-right: 20px; + margin-bottom: 13px; white-space: nowrap; + + @include tablet { + margin-bottom: 0; + } + + @include desktop { + margin-right: 40px; + } } .periodWeekPicker { - margin-left: 40px; + margin-bottom: 13px; + + @include tablet { + margin-bottom: 0; + } +} + +.periodsPaginationTop { + width: 100%; + + @include tablet { + margin-left: 20px; + width: auto; + min-width: 382px; + } + + @include desktop { + margin-left: 40px; + min-width: 505px; + } } -.periodsPagination { - margin-left: 40px; +.periodsFooter { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 13px 13px 13px 32px; } 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 88e2385..4c8d320 100644 --- a/src/services/workPeriods.js +++ b/src/services/workPeriods.js @@ -1,10 +1,12 @@ 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, + RB_API_URL, + TAAS_TEAM_API_URL, WORK_PERIODS_API_URL, } from "constants/workPeriods"; import { buildRequestQuery, extractResponseData } from "utils/misc"; @@ -38,7 +40,7 @@ export const fetchJob = (jobId, source) => { */ export const fetchProject = (projectId) => { return axios - .get(`${PROJECTS_API_URL}/${projectId}?fields=projectId,name`) + .get(`${TAAS_TEAM_API_URL}/${projectId}`) .then(extractResponseData); }; @@ -100,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. * @@ -140,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 de5fdf2..d7fb1ac 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -6,8 +6,6 @@ export const WP_HIGHLIGHT_FAILED_PERIODS = "WP_HIGHLIGHT_FAILED_PERIODS"; export const WP_LOAD_PERIOD_DETAILS_PENDING = "WP_LOAD_PERIOD_DETAILS_PENDING"; export const WP_LOAD_PERIOD_DETAILS_ERROR = "WP_LOAD_PERIOD_DETAILS_ERROR"; export const WP_LOAD_PERIOD_DETAILS_SUCCESS = "WP_LOAD_PERIOD_DETAILS_SUCCESS"; -export const WP_LOAD_JOB_NAME_ERROR = "WP_LOAD_JOB_NAME_ERROR"; -export const WP_LOAD_JOB_NAME_SUCCESS = "WP_LOAD_JOB_NAME_SUCCESS"; export const WP_LOAD_BILLING_ACCOUNTS_ERROR = "WP_LOAD_BILLING_ACCOUNTS_ERROR"; export const WP_LOAD_BILLING_ACCOUNTS_SUCCESS = "WP_LOAD_BILLING_ACCOUNTS_SUCCESS"; @@ -20,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"; @@ -29,9 +28,13 @@ 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"; export const WP_TOGGLE_PERIODS_VISIBLE = "WP_TOGGLE_PERIODS_VISIBLE"; export const WP_TOGGLE_PROCESSING_PAYMENTS = "WP_TOGGLE_PROCESSING_PAYMENTS"; +export const WP_TOGGLE_WORKING_DAYS_UPDATED = "WP_TOGGLE_WORKING_DAYS_UPDATED"; export const WP_UPDATE_STATE_FROM_QUERY = "WP_UPDATE_STATE_FROM_QUERY"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index e1e72a4..11f83cc 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -54,20 +54,14 @@ export const hideWorkPeriodDetails = (periodId) => ({ /** * Creates an action denoting the loading of working period's details. * - * @param {string} periodId working period id - * @param {string} rbId resource booking id - * @param {number} billingAccountId billing account id + * @param {Object} period working period object with basic data such as id, + * rbId, jobId, billingAccountId and etc * @param {Object} cancelSource axios cancel token source * @returns {Object} */ -export const loadWorkPeriodDetailsPending = ( - periodId, - rbId, - billingAccountId, - cancelSource -) => ({ +export const loadWorkPeriodDetailsPending = (period, cancelSource) => ({ type: ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_PENDING, - payload: { periodId, rbId, billingAccountId, cancelSource }, + payload: { period, cancelSource }, }); /** @@ -95,52 +89,28 @@ export const loadWorkPeriodDetailsError = (periodId, message) => ({ payload: { periodId, message, id: nextErrorId++ }, }); -/** - * Creates an action denoting successful loading of resource booking's job name. - * - * @param {string} periodId working period id - * @param {string} jobName working period job name - * @returns {Object} - */ -export const loadJobNameSuccess = (periodId, jobName) => ({ - type: ACTION_TYPE.WP_LOAD_JOB_NAME_SUCCESS, - payload: { periodId, jobName }, -}); - -/** - * Creates an action denoting an error for loading resource booking's job name. - * - * @param {string} periodId working period id - * @param {string} message error message - * @returns {Object} - */ -export const loadJobNameError = (periodId, message) => ({ - type: ACTION_TYPE.WP_LOAD_JOB_NAME_ERROR, - payload: { periodId, message, id: nextErrorId++ }, -}); - /** * 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++ }, }); /** @@ -306,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. * @@ -319,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 }, }); @@ -384,11 +383,24 @@ export const toggleWorkingPeriodsVisible = (on = null) => ({ * @param {?boolean} on whether to turn processing-payments state on or off * @returns {Object} */ -export const toggleWorkPeriodsProcessingPeyments = (on = null) => ({ +export const toggleWorkPeriodsProcessingPayments = (on = null) => ({ type: ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS, payload: on, }); +/** + * Creates an action denoting the change of working-days-updated flag for + * working period with the specified id. + * + * @param {string} periodId working period id + * @param {boolean} on whether to toggle working-days-updated flag on or off. + * @returns {Object} + */ +export const toggleWorkingDaysUpdated = (periodId, on) => ({ + type: ACTION_TYPE.WP_TOGGLE_WORKING_DAYS_UPDATED, + payload: { periodId, on }, +}); + /** * Creates an action denoting an update of working periods state slice using * the provided query. diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index e34ddeb..4ca2844 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -4,21 +4,28 @@ import { BILLING_ACCOUNTS_NONE, BILLING_ACCOUNTS_LOADING, BILLING_ACCOUNTS_ERROR, - JOB_NAME_ERROR, - JOB_NAME_LOADING, PAYMENT_STATUS, SORT_BY, SORT_BY_DEFAULT, SORT_ORDER, SORT_ORDER_DEFAULT, URL_QUERY_PARAM_MAP, + REASON_DISABLED, + ALERT, } from "constants/workPeriods"; import { filterPeriodsByStartDate, getWeekByDate, updateOptionMap, } from "utils/misc"; -import { createAssignedBillingAccountOption } from "utils/workPeriods"; +import { + addValueImmutable, + computeDaysWorkedMax, + createPeriodAlerts, + createAssignedBillingAccountOption, + findReasonsDisabled, + removeValueImmutable, +} from "utils/workPeriods"; const cancelSourceDummy = { cancel: () => {} }; @@ -38,19 +45,18 @@ const initFilters = () => ({ userHandle: "", }); +const initPeriodData = (period) => { + const data = period.data; + data.cancelSource = null; + data.daysWorkedIsUpdated = false; + return data; +}; + const initPeriodDetails = ( - periodId, - rbId, billingAccountId = 0, cancelSource = cancelSourceDummy ) => ({ - periodId, - rbId, cancelSource, - jobName: JOB_NAME_LOADING, - jobNameError: null, - jobNameIsLoading: true, - billingAccountId, billingAccounts: [ { value: billingAccountId, label: BILLING_ACCOUNTS_LOADING }, ], @@ -72,10 +78,13 @@ const initialState = updateStateFromQuery(window.location.search, { isSelectedPeriodsVisible: false, pagination: initPagination(), periods: [], + periodsAlerts: {}, + periodsById: {}, periodsData: [{}], periodsDetails: {}, + periodsDisabled: [new Map()], periodsFailed: {}, - periodsSelected: {}, + periodsSelected: [new Set()], sorting: { criteria: SORT_BY_DEFAULT, order: SORT_ORDER_DEFAULT, @@ -97,10 +106,13 @@ const actionHandlers = { isSelectedPeriodsAll: false, isSelectedPeriodsVisible: false, periods: [], + periodsAlerts: {}, + periodsById: {}, periodsData: [{}], periodsDetails: {}, + periodsDisabled: [new Map()], periodsFailed: {}, - periodsSelected: {}, + periodsSelected: [new Set()], }), [ACTION_TYPE.WP_LOAD_PAGE_SUCCESS]: ( state, @@ -112,10 +124,31 @@ const actionHandlers = { oldPagination.pageCount !== pageCount ? { ...oldPagination, totalCount, pageCount } : oldPagination; + const periodsAlerts = {}; + const periodsById = {}; const periodsData = {}; + const periodsDisabledMap = new Map(); + const dateRange = state.filters.dateRange; + const periodStart = dateRange[0]; + const periodEnd = dateRange[1]; for (let period of periods) { - period.data.cancelSource = null; - periodsData[period.id] = period.data; + periodsById[period.id] = true; + let periodData = initPeriodData(period); + periodData.daysWorkedMax = computeDaysWorkedMax( + period.bookingStart, + period.bookingEnd, + periodStart, + periodEnd + ); + periodsData[period.id] = periodData; + let reasonsDisabled = findReasonsDisabled(period); + if (reasonsDisabled) { + periodsDisabledMap.set(period.id, reasonsDisabled); + } + let alerts = createPeriodAlerts(period, periodEnd); + if (alerts) { + periodsAlerts[period.id] = alerts; + } delete period.data; } return { @@ -124,7 +157,10 @@ const actionHandlers = { error: null, pagination, periods, + periodsAlerts, + periodsById, periodsData: [periodsData], + periodsDisabled: [periodsDisabledMap], }; }, [ACTION_TYPE.WP_LOAD_PAGE_ERROR]: (state, error) => { @@ -133,7 +169,6 @@ const actionHandlers = { ...state, cancelSource: null, error: error.message, - periods: [], }; }, [ACTION_TYPE.WP_HIDE_PERIOD_DETAILS]: (state, periodId) => { @@ -149,37 +184,35 @@ const actionHandlers = { if (!periodIds.length) { return state; } - let isSelectedPeriodsAll = state.isSelectedPeriodsAll; - let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; - const periodsFailed = { ...state.periodsFailed }; - const periodsSelected = { ...state.periodsSelected }; + const periodsFailed = {}; + const periodsSelectedSet = state.periodsSelected[0]; + const oldPeriodsSelectedCount = periodsSelectedSet.size; for (let periodId of periodIds) { - if (periods[periodId]) { - periodsFailed[periodId] = true; - periodsSelected[periodId] = true; + let error = periods[periodId]; + if (error) { + periodsFailed[periodId] = error; + periodsSelectedSet.add(periodId); } else { - isSelectedPeriodsAll = false; - isSelectedPeriodsVisible = false; - delete periodsSelected[periodId]; + periodsSelectedSet.delete(periodId); } } - return { + state = { ...state, - isSelectedPeriodsAll, - isSelectedPeriodsVisible, periodsFailed, - periodsSelected, }; + if (periodsSelectedSet.size !== oldPeriodsSelectedCount) { + state.periodsSelected = [periodsSelectedSet]; + updateSelectedPeriodsFlags(state); + } + return state; }, [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_PENDING]: ( state, - { periodId, rbId, billingAccountId, cancelSource } + { period, cancelSource } ) => { const periodsDetails = { ...state.periodsDetails }; - periodsDetails[periodId] = initPeriodDetails( - periodId, - rbId, - billingAccountId, + periodsDetails[period.id] = initPeriodDetails( + period.billingAccountId, cancelSource ); return { @@ -191,17 +224,32 @@ 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) { // This branch should not be reachable but just in case. return state; } + const periods = state.periods; + let period = null; + for (let i = 0, len = periods.length; i < len; i++) { + period = periods[i]; + if (period.id === periodId) { + break; + } + } + const { bookingStart, bookingEnd } = period; const periodsData = state.periodsData[0]; for (let period of details.periods) { - period.data.cancelSource = null; - periodsData[period.id] = period.data; + let periodData = initPeriodData(period); + periodData.daysWorkedMax = computeDaysWorkedMax( + bookingStart, + bookingEnd, + period.start, + period.end + ); + periodsData[period.id] = periodData; delete period.data; } periodDetails = { @@ -209,6 +257,9 @@ const actionHandlers = { periods: details.periods, periodsIsLoading: false, }; + if (!periodDetails.billingAccountsIsLoading) { + periodDetails.cancelSource = null; + } if (periodDetails.hidePastPeriods) { periodDetails.periodsVisible = filterPeriodsByStartDate( periodDetails.periods, @@ -221,7 +272,7 @@ const actionHandlers = { return { ...state, periodsData: [periodsData], - periodsDetails, + periodsDetails: { ...periodsDetails }, }; }, [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_ERROR]: ( @@ -237,61 +288,28 @@ const actionHandlers = { periodsDetails, }; }, - [ACTION_TYPE.WP_LOAD_JOB_NAME_SUCCESS]: (state, { periodId, jobName }) => { - const periodsDetails = { ...state.periodsDetails }; - let periodDetails = periodsDetails[periodId]; - if (!periodDetails) { - // Period details may be removed at this point so we must handle this case. - return state; - } - periodDetails = { - ...periodDetails, - jobName, - jobNameError: null, - jobNameIsLoading: false, - }; - if (!periodDetails.billingAccountsIsLoading) { - periodDetails.cancelSource = null; - } - periodsDetails[periodId] = periodDetails; - return { - ...state, - periodsDetails, - }; - }, - [ACTION_TYPE.WP_LOAD_JOB_NAME_ERROR]: (state, { periodId, message }) => { - const periodsDetails = { ...state.periodsDetails }; - let periodDetails = periodsDetails[periodId]; - if (!periodDetails) { - return state; - } - periodDetails = { - ...periodDetails, - jobName: JOB_NAME_ERROR, - jobNameError: message, - jobNameIsLoading: false, - }; - if (!periodDetails.billingAccountsIsLoading) { - periodDetails.cancelSource = null; - } - periodsDetails[periodId] = periodDetails; - return { - ...state, - periodsDetails, - }; - }, [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; @@ -303,27 +321,27 @@ const actionHandlers = { billingAccountsIsDisabled, billingAccountsIsLoading: false, }; - if (!periodDetails.jobNameIsLoading) { + 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; @@ -337,29 +355,57 @@ const actionHandlers = { billingAccountsIsDisabled, billingAccountsIsLoading: false, }; - if (!periodDetails.jobNameIsLoading) { + 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 }) => { - const 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, - }; - return { + 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 = removeValueImmutable( + oldReasonsDisabled, + REASON_DISABLED.NO_BILLING_ACCOUNT + ); + if (oldReasonsDisabled !== reasonsDisabled) { + if (reasonsDisabled) { + periodsDisabledMap.set(periodId, reasonsDisabled); + } else { + periodsDisabledMap.delete(periodId); + } + 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]: ( state, @@ -390,16 +436,25 @@ const actionHandlers = { { periodId, daysWorked } ) => { const periodsData = state.periodsData[0]; - let periodData = periodsData[periodId]; - daysWorked = Math.min(Math.max(daysWorked, periodData.daysPaid), 5); + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + daysWorked = Math.min( + Math.max(daysWorked, periodData.daysPaid), + periodData.daysWorkedMax + ); if (daysWorked === periodData.daysWorked) { return state; } periodsData[periodId] = { ...periodData, daysWorked }; - return { + state = { ...state, periodsData: [periodsData], }; + return periodId in state.periodsById + ? updateStateAfterWorkingDaysChange(periodId, state) + : state; }, [ACTION_TYPE.WP_RESET_FILTERS]: (state) => ({ ...state, @@ -428,35 +483,19 @@ const actionHandlers = { }; }, [ACTION_TYPE.WP_SELECT_PERIODS]: (state, periods) => { - let isSelectedPeriodsAll = state.isSelectedPeriodsAll; - let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; - let periodsSelected = { ...state.periodsSelected }; + const periodsSelectedSet = state.periodsSelected[0]; for (let periodId in periods) { if (periods[periodId] === true) { - periodsSelected[periodId] = true; + periodsSelectedSet.add(periodId); } else { - isSelectedPeriodsAll = false; - isSelectedPeriodsVisible = false; - delete periodsSelected[periodId]; - } - } - const selectedCount = Object.keys(periodsSelected).length; - const pageSize = state.pagination.pageSize; - const totalCount = state.pagination.totalCount; - if (totalCount > pageSize) { - if (selectedCount === pageSize) { - isSelectedPeriodsVisible = true; + periodsSelectedSet.delete(periodId); } - } else if (selectedCount === totalCount) { - isSelectedPeriodsAll = true; - isSelectedPeriodsVisible = true; } - return { + state = { ...state, - isSelectedPeriodsAll, - isSelectedPeriodsVisible, - periodsSelected, + periodsSelected: [periodsSelectedSet], }; + return updateSelectedPeriodsFlags(state); }, [ACTION_TYPE.WP_SET_PAGE_NUMBER]: (state, pageNumber) => ({ ...state, @@ -553,7 +592,7 @@ const actionHandlers = { }, [ACTION_TYPE.WP_SET_PERIOD_DATA_SUCCESS]: (state, { periodId, data }) => { const periodsData = state.periodsData[0]; - const periodData = periodsData[periodId]; + let periodData = periodsData[periodId]; if (!periodData) { return state; } @@ -562,12 +601,16 @@ const actionHandlers = { ...data, cancelSource: null, }; - return { + state = { ...state, periodsData: [periodsData], }; + return periodId in state.periodsById + ? 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) { @@ -582,17 +625,105 @@ const actionHandlers = { 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, + periodsData: [periodsData], + }; + }, [ACTION_TYPE.WP_SET_WORKING_DAYS]: (state, { periodId, daysWorked }) => { const periodsData = state.periodsData[0]; const periodData = periodsData[periodId]; if (!periodData) { return state; } - daysWorked = Math.min(Math.max(daysWorked, periodData.daysPaid), 5); + daysWorked = Math.min( + Math.max(daysWorked, periodData.daysPaid), + periodData.daysWorkedMax + ); if (daysWorked === periodData.daysWorked) { return state; } periodsData[periodId] = { ...periodData, daysWorked }; + return updateStateAfterWorkingDaysChange(periodId, { + ...state, + 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], @@ -617,69 +748,74 @@ const actionHandlers = { }; }, [ACTION_TYPE.WP_TOGGLE_PERIOD]: (state, periodId) => { - let isSelectedPeriodsAll = state.isSelectedPeriodsAll; - let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; - const periodsSelected = { ...state.periodsSelected }; - const isSelected = !periodsSelected[periodId]; - if (isSelected) { - periodsSelected[periodId] = true; - const selectedCount = Object.keys(periodsSelected).length; - const pageSize = state.pagination.pageSize; - const totalCount = state.pagination.totalCount; - if (totalCount > pageSize) { - if (selectedCount === pageSize) { - isSelectedPeriodsVisible = true; - } - } else if (selectedCount === totalCount) { - isSelectedPeriodsAll = true; - isSelectedPeriodsVisible = true; - } + const periodsSelectedSet = state.periodsSelected[0]; + if (periodsSelectedSet.has(periodId)) { + periodsSelectedSet.delete(periodId); } else { - isSelectedPeriodsAll = false; - isSelectedPeriodsVisible = false; - delete periodsSelected[periodId]; + periodsSelectedSet.add(periodId); } - return { + return updateSelectedPeriodsFlags({ ...state, - periodsSelected, - isSelectedPeriodsAll, - isSelectedPeriodsVisible, - }; + periodsSelected: [periodsSelectedSet], + }); }, [ACTION_TYPE.WP_TOGGLE_PERIODS_ALL]: (state, on) => { + const periodsSelectedSet = new Set(); const isSelected = on === null ? !state.isSelectedPeriodsAll : on; - const periodsSelected = {}; if (isSelected) { + const periodsDisabledMap = state.periodsDisabled[0]; for (let period of state.periods) { - periodsSelected[period.id] = true; + let periodId = period.id; + if (!periodsDisabledMap.has(periodId)) { + periodsSelectedSet.add(periodId); + } } } return { ...state, - periodsSelected, + periodsSelected: [periodsSelectedSet], isSelectedPeriodsAll: isSelected, isSelectedPeriodsVisible: isSelected, }; }, [ACTION_TYPE.WP_TOGGLE_PERIODS_VISIBLE]: (state, on) => { + const periodsSelectedSet = new Set(); let isSelectedPeriodsAll = false; const isSelectedPeriodsVisible = on === null ? !state.isSelectedPeriodsVisible : on; - const periodsSelected = {}; if (isSelectedPeriodsVisible) { + const periodsDisabledMap = state.periodsDisabled[0]; for (let period of state.periods) { - periodsSelected[period.id] = true; + let periodId = period.id; + if (!periodsDisabledMap.has(periodId)) { + periodsSelectedSet.add(periodId); + } } isSelectedPeriodsAll = state.periods.length === state.pagination.totalCount; } return { ...state, - periodsSelected, + periodsSelected: [periodsSelectedSet], isSelectedPeriodsAll, isSelectedPeriodsVisible, }; }, + [ACTION_TYPE.WP_TOGGLE_WORKING_DAYS_UPDATED]: (state, { periodId, on }) => { + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData || periodData.daysWorkedIsUpdated === on) { + return state; + } + periodsData[periodId] = { + ...periodData, + daysWorkedIsUpdated: on, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, [ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS]: (state, on) => { let periodsFailed = state.periodsFailed; let isProcessingPayments = on === null ? !state.isProcessingPayments : on; @@ -696,6 +832,65 @@ const actionHandlers = { updateStateFromQuery(query, state), }; +function updateStateAfterWorkingDaysChange(periodId, state) { + const periodData = state.periodsData[0][periodId]; + const periodsDisabledMap = state.periodsDisabled[0]; + const oldReasonsDisabled = periodsDisabledMap.get(periodId); + let reasonsDisabled = + periodData.daysWorked === periodData.daysPaid + ? addValueImmutable( + oldReasonsDisabled, + REASON_DISABLED.NO_DAYS_TO_PAY_FOR + ) + : removeValueImmutable( + oldReasonsDisabled, + REASON_DISABLED.NO_DAYS_TO_PAY_FOR + ); + if (oldReasonsDisabled !== reasonsDisabled) { + const periodsSelectedSet = state.periodsSelected[0]; + const oldPeriodsSelectedCount = periodsSelectedSet.size; + if (reasonsDisabled) { + periodsDisabledMap.set(periodId, reasonsDisabled); + periodsSelectedSet.delete(periodId); + } else { + periodsDisabledMap.delete(periodId); + } + state.periodsDisabled = [periodsDisabledMap]; + if (periodsSelectedSet.size !== oldPeriodsSelectedCount) { + state.periodsSelected = [periodsSelectedSet]; + } + updateSelectedPeriodsFlags(state); + } + return state; +} + +function updateSelectedPeriodsFlags(state) { + let isSelectedPeriodsAll = state.isSelectedPeriodsAll; + let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; + const selectedCount = state.periodsSelected[0].size; + const pageSize = state.pagination.pageSize; + const totalCount = state.pagination.totalCount; + const maxSelectedOnPageCount = + Math.min(pageSize, totalCount) - state.periodsDisabled[0].size; + if (totalCount > pageSize) { + if (selectedCount === maxSelectedOnPageCount) { + isSelectedPeriodsVisible = true; + } else { + isSelectedPeriodsAll = false; + isSelectedPeriodsVisible = false; + } + } else if (selectedCount === maxSelectedOnPageCount) { + isSelectedPeriodsAll = true; + isSelectedPeriodsVisible = true; + } else { + isSelectedPeriodsAll = false; + isSelectedPeriodsVisible = false; + } + state.isSelectedPeriodsAll = isSelectedPeriodsAll; + state.isSelectedPeriodsVisible = isSelectedPeriodsVisible; + return state; +} + /** * Updates state from current URL's query. * diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js index 9a826e7..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. * @@ -23,6 +31,16 @@ export const getWorkPeriods = (state) => state.workPeriods.periods; export const getWorkPeriodsDetails = (state) => state.workPeriods.periodsDetails; +/** + * Returns a Map wrapped in an array containing arrays of reasons the specific + * working period is disabled. + * + * @param {Object} state redux root state + * @returns {[Map]} + */ +export const getWorkPeriodsDisabled = (state) => + state.workPeriods.periodsDisabled; + /** * Returns an object which has working periods' ids for which the payments * were failed to be scheduled as keys. @@ -33,15 +51,17 @@ export const getWorkPeriodsDetails = (state) => export const getWorkPeriodsFailed = (state) => state.workPeriods.periodsFailed; /** - * Returns an object with working periods' ids as keys and booleans showing - * whether the period is selected as values. + * Returns a Set wrapped in array containing the ids of selected working periods. * * @param {Object} state redux root state - * @returns {Object.} + * @returns {[Set]} */ export const getWorkPeriodsSelected = (state) => state.workPeriods.periodsSelected; +export const getWorkPeriodsSelectedCount = (state) => + state.workPeriods.periodsSelected[0].size; + /** * Returns working periods filters' state. * @@ -68,11 +88,6 @@ export const getWorkPeriodsPageNumber = (state) => export const getWorkPeriodsPageSize = (state) => state.workPeriods.pagination.pageSize; -export const getWorkPeriodsUrlQuery = (state) => state.workPeriods.query; - -export const getWorkPeriodsIsQueryFromState = (state) => - state.workPeriods.isQueryFromState; - export const getWorkPeriodsCount = (state) => state.workPeriods.periods.length; export const getWorkPeriodsData = (state) => state.workPeriods.periodsData; @@ -80,13 +95,8 @@ export const getWorkPeriodsData = (state) => state.workPeriods.periodsData; export const getWorkPeriodsTotalCount = (state) => state.workPeriods.pagination.totalCount; -export const getWorkPeriodsHasSelectedItems = (state) => { - const periodsSelected = state.workPeriods.periodsSelected; - for (let id in periodsSelected) { - return true; - } - return false; -}; +export const getWorkPeriodsHasSelectedItems = (state) => + !!state.workPeriods.periodsSelected[0].size; export const getWorkPeriodsIsLoading = (state) => !!state.workPeriods.cancelSource; diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index a406918..755bb71 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -9,11 +9,9 @@ import { DATE_FORMAT_API, PAYMENT_STATUS_MAP, API_FIELDS_QUERY, - JOB_NAME_NONE, API_CHALLENGE_PAYMENT_STATUS, } from "constants/workPeriods"; import { - extractJobName, extractResponseData, extractResponsePagination, replaceItems, @@ -33,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 @@ -150,30 +188,7 @@ export const toggleWorkPeriodDetails = // reload details? } else { const source = axios.CancelToken.source(); - dispatch( - actions.loadWorkPeriodDetailsPending( - period.id, - period.rbId, - period.billingAccountId, - source - ) - ); - - if (period.jobId) { - const [jobNamePromise] = services.fetchJob(period.jobId, source); - jobNamePromise - .then((data) => { - const jobName = extractJobName(data); - dispatch(actions.loadJobNameSuccess(period.id, jobName)); - }) - .catch((error) => { - if (!axios.isCancel(error)) { - dispatch(actions.loadJobNameError(period.id, error.toString())); - } - }); - } else { - dispatch(actions.loadJobNameSuccess(period.id, JOB_NAME_NONE)); - } + dispatch(actions.loadWorkPeriodDetailsPending(period, source)); const [bilAccsPromise] = services.fetchBillingAccounts( period.projectId, @@ -181,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()) ); } }); @@ -256,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 { @@ -280,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)); } }; @@ -293,16 +303,26 @@ export const updateWorkPeriodWorkingDays = * @param {function} getState function returning redux store root state */ export const processPayments = async (dispatch, getState) => { - dispatch(actions.toggleWorkPeriodsProcessingPeyments(true)); const state = getState(); + const isProcessing = selectors.getWorkPeriodsIsProcessingPayments(state); + if (isProcessing) { + return; + } + dispatch(actions.toggleWorkPeriodsProcessingPayments(true)); const isSelectedAll = selectors.getWorkPeriodsIsSelectedAll(state); const { pageSize, totalCount } = selectors.getWorkPeriodsPagination(state); - if (isSelectedAll && totalCount > pageSize) { - processPaymentsAll(dispatch, getState); - } else { - processPaymentsSpecific(dispatch, getState); + const promise = + isSelectedAll && totalCount > pageSize + ? processPaymentsAll(dispatch, getState) + : processPaymentsSpecific(dispatch, getState); + // The promise returned by processPaymentsAll or processPaymentsSpecific + // should never be rejected but adding try-catch block just in case. + try { + await promise; + } catch (error) { + console.error(error); } - dispatch(actions.toggleWorkPeriodsProcessingPeyments(false)); + dispatch(actions.toggleWorkPeriodsProcessingPayments(false)); }; const processPaymentsAll = async (dispatch, getState) => { @@ -352,13 +372,10 @@ const processPaymentsAll = async (dispatch, getState) => { const processPaymentsSpecific = async (dispatch, getState) => { const state = getState(); - const periods = selectors.getWorkPeriods(state); - const periodsSelected = selectors.getWorkPeriodsSelected(state); + const [periodsSelectedSet] = selectors.getWorkPeriodsSelected(state); const payments = []; - for (let period of periods) { - if (period.id in periodsSelected) { - payments.push({ workPeriodId: period.id }); - } + for (let workPeriodId of periodsSelectedSet) { + payments.push({ workPeriodId }); } makeToastPaymentsProcessing(payments.length); let results = null; @@ -373,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); @@ -387,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/formatters.js b/src/utils/formatters.js index d3c0a17..5dca94a 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -1,6 +1,6 @@ import moment from "moment"; import isNumber from "lodash/isNumber"; -import { PAYMENT_STATUS_LABELS } from "constants/workPeriods"; +import { DATE_FORMAT_UI, PAYMENT_STATUS_LABELS } from "constants/workPeriods"; import { PLATFORM_WEBSITE_URL, TAAS_BASE_PATH, @@ -19,6 +19,16 @@ export function formatChallengeUrl(challengeId) { return `${TOPCODER_WEBSITE_URL}/challenges/${challengeId}`; } +/** + * Returns formatted date for working period rows. + * + * @param {any} date any value accepted by MomentJS + * @returns {string} + */ +export function formatDate(date) { + return date ? moment(date).format(DATE_FORMAT_UI) : "-"; +} + /** * Returns a string denoting whether the specified start date corresponds to the * current period or future period. @@ -71,6 +81,28 @@ export function formatPaymentStatus(status) { return paymentStatus; } +/** + * Creates the string with the number of items and the word describing the item + * possibly in plural form. + * + * @param {number} count number of items + * @param {string} baseWord word describing the item + * @returns {string} + */ +export function formatPlural(count, baseWord) { + return `${count} ${baseWord}${count > 1 ? "s" : ""}`; +} + +/** + * Returns "is" or "are" for singular/plural phrases. + * + * @param {number} count + * @returns {string} "is" or "are" + */ +export function formatIsAre(count) { + return count > 1 ? "are" : "is"; +} + /** * Formats user handle link. * 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 0811244..968a5c9 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -15,7 +15,7 @@ export function filterPeriodsByStartDate(periods, startDate) { const items = []; startDate = moment(startDate); for (let period of periods) { - if (moment(period.startDate).isSameOrAfter(startDate, "date")) { + if (period.start.isSameOrAfter(startDate, "date")) { items.push(period); } } @@ -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. @@ -146,6 +151,13 @@ export const extractJobName = (data) => data.title; export const extractResponseData = (response) => response.data; +export const hasKey = (obj) => { + for (let key in obj) { + return true; + } + return false; +}; + export const increment = (value) => value + 1; export const negate = (value) => !value; diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index ec97f28..82ebbae 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -1,13 +1,108 @@ import moment from "moment"; import { + ALERT, API_CHALLENGE_PAYMENT_STATUS_MAP, API_PAYMENT_STATUS_MAP, DATE_FORMAT_API, - DATE_FORMAT_UI, + DATE_FORMAT_ISO, PAYMENT_STATUS, + REASON_DISABLED, URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; +/** + * Computes maximum allowed working days based on resource booking start and end + * dates and working period start and end dates. + * + * @param {string} bookingStart resource booking start date + * @param {string} bookingEnd resource booking end date + * @param {Object} periodStart working period start date + * @param {Object} periodEnd working period end date + * @returns {number} + */ +export function computeDaysWorkedMax( + bookingStart, + bookingEnd, + periodStart, + periodEnd +) { + let start = periodStart.day() + 1; // Monday + let end = periodEnd.day() - 1; // Friday + if (periodStart.isBefore(bookingStart, "date")) { + // booking starts from Monday, Tuesday and so on + start = moment(bookingStart).day(); + } + if (periodEnd.isAfter(bookingEnd, "date")) { + // booking ends at Friday, Thursday and so on + end = moment(bookingEnd).day(); + } + return end - start + 1; +} + +/** + * Returns an array of working period's alert ids. + * + * @param {Object} period working period basic data object containing + * resource booking end date + * @param {Object} periodEnd Moment object with working period end + * @returns {Array} + */ +export function createPeriodAlerts(period, periodEnd) { + const alerts = []; + if (!period.billingAccountId) { + alerts.push(ALERT.BA_NOT_ASSIGNED); + } + if (periodEnd.isSameOrAfter(period.bookingEnd, "date")) { + alerts.push(ALERT.LAST_BOOKING_WEEK); + } + return alerts.length ? alerts : undefined; +} + +/** + * Checks for reasons the specified working period should be disabled for + * payment processing. + * + * @param {Object} period working period object + * @returns {?string[]} + */ +export function findReasonsDisabled(period) { + const reasons = []; + if (!period.billingAccountId) { + reasons.push(REASON_DISABLED.NO_BILLING_ACCOUNT); + } + if (!period.weeklyRate) { + reasons.push(REASON_DISABLED.NO_MEMBER_RATE); + } + const data = period.data; + if (data && data.daysWorked === data.daysPaid) { + reasons.push(REASON_DISABLED.NO_DAYS_TO_PAY_FOR); + } + return reasons.length ? reasons : undefined; +} + +export function addValueImmutable(items, value) { + if (!items) { + return [value]; + } + if (items.indexOf(value) < 0) { + items = [...items, value]; + } + return items; +} + +export function removeValueImmutable(items, value) { + if (!items) { + return undefined; + } + let index = items.indexOf(value); + if (index >= 0) { + let newItems = [...items]; + newItems.splice(index, 1); + return newItems.length ? newItems : undefined; + } + return items; +} + /** * Creates a URL search query from current state. * @@ -54,10 +149,14 @@ export function normalizePeriodItems(items) { billingAccountId: billingAccountId === null ? 0 : billingAccountId, teamName: "", userHandle: workPeriod.userHandle || "", - startDate: item.startDate - ? moment(item.startDate).format(DATE_FORMAT_UI) + // resource booking period start date + bookingStart: item.startDate + ? moment(item.startDate).format(DATE_FORMAT_ISO) + : "", + // resource booking period end date + bookingEnd: item.endDate + ? moment(item.endDate).format(DATE_FORMAT_ISO) : "", - endDate: item.endDate ? moment(item.endDate).format(DATE_FORMAT_UI) : "", weeklyRate: item.memberRate, data: normalizePeriodData(workPeriod), }); @@ -70,13 +169,17 @@ export function normalizeDetailsPeriodItems(items) { for (let item of items) { periods.push({ id: item.id, - startDate: item.startDate ? moment(item.startDate).valueOf() : 0, - endDate: item.endDate ? moment(item.endDate).valueOf() : 0, + // working period start date + start: moment(item.startDate || undefined), + // working period end date + end: moment(item.endDate || undefined), weeklyRate: item.memberRate, data: normalizePeriodData(item), }); } - periods.sort(sortByStartDate); + periods.sort( + (periodA, periodB) => periodA.start.valueOf() - periodB.start.valueOf() + ); return periods; } @@ -94,8 +197,8 @@ export function normalizeDetailsPeriodItems(items) { */ export function normalizePeriodData(period) { const data = { - daysWorked: period.daysWorked === null ? 5 : +period.daysWorked || 0, daysPaid: +period.daysPaid || 0, + daysWorked: period.daysWorked === null ? 5 : +period.daysWorked || 0, paymentStatus: normalizePaymentStatus(period.paymentStatus), paymentTotal: +period.paymentTotal || 0, }; @@ -103,19 +206,26 @@ 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.createdAt = moment(payment.createdAt).valueOf(); + payment.status = normalizeChallengePaymentStatus(payment.status); if (payment.status === PAYMENT_STATUS.FAILED) { lastFailedPayment = payment; } } data.paymentErrorLast = lastFailedPayment?.statusDetails; - data.payments = payments; + data.payments = payments.sort( + (paymentA, paymentB) => paymentA.createdAt - paymentB.createdAt + ); } 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]; } @@ -125,15 +235,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") : ""; @@ -142,16 +249,9 @@ export function normalizeBillingAccounts(accounts, accountId = -1) { label: `${acc.name} (${value})` + (endDate ? ` - ${endDate}` : ""), }); } - if (!hasSelectedAccount && accountId > 0) { - accs.unshift(createAssignedBillingAccountOption(accountId)); - } return accs; } export function createAssignedBillingAccountOption(accountId) { return { value: accountId, label: ` (${accountId})` }; } - -export function sortByStartDate(itemA, itemB) { - return itemA.startDate - itemB.startDate; -}