diff --git a/config/dev.js b/config/dev.js index 04cc1f2..ea415de 100644 --- a/config/dev.js +++ b/config/dev.js @@ -1,6 +1,8 @@ module.exports = { API: { + V3: "https://api.topcoder-dev.com/v3", V5: "https://api.topcoder-dev.com/v5", }, PLATFORM_WEBSITE_URL: "https://platform.topcoder-dev.com", + TOPCODER_WEBSITE_URL: "https://www.topcoder-dev.com", }; diff --git a/config/index.js b/config/index.js index 8511e16..ba0ff0d 100644 --- a/config/index.js +++ b/config/index.js @@ -2,9 +2,5 @@ module.exports = (() => { const appEnv = process.env.APPENV === "prod" ? "prod" : "dev"; - - // eslint-disable-next-line no-console - console.log(`APPENV: "${appEnv}"`); - return require(`./${appEnv}`); })(); diff --git a/config/prod.js b/config/prod.js index 7d0f7c4..e539e99 100644 --- a/config/prod.js +++ b/config/prod.js @@ -1,6 +1,8 @@ module.exports = { API: { + V3: "https://api.topcoder.com/v3", V5: "https://api.topcoder.com/v5", }, PLATFORM_WEBSITE_URL: "https://platform.topcoder.com", + TOPCODER_WEBSITE_URL: "https://www.topcoder.com", }; diff --git a/src/assets/images/icon-computer.svg b/src/assets/images/icon-computer.svg new file mode 100644 index 0000000..2ac6647 --- /dev/null +++ b/src/assets/images/icon-computer.svg @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="21px" height="24px" viewBox="0 0 21 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="02E-Job-Details" transform="translate(-331.000000, -213.000000)" fill="#555555" fill-rule="nonzero"> + <g id="Group" transform="translate(305.000000, 182.000000)"> + <g id="info-copy" transform="translate(24.000000, 24.000000)"> + <g id="desktop-computer" transform="translate(2.000000, 7.000000)"> + <g> + <path d="M2.25,15 C1.00781635,14.9988973 0.00110269364,13.9921836 0,12.75 L0,2.25 C0,1.01 1.009,0 2.25,0 L18.75,0 C19.9921836,0.00110269364 20.9988973,1.00781635 21,2.25 L21,12.75 C20.9988973,13.9921836 19.9921836,14.9988973 18.75,15 L2.25,15 Z M1.5,12.75 C1.5,13.164 1.836,13.5 2.25,13.5 L18.75,13.5 C19.1642136,13.5 19.5,13.1642136 19.5,12.75 L19.5,12 L1.5,12 L1.5,12.75 Z M19.5,10.5 L19.5,2.25 C19.5,1.83578644 19.1642136,1.5 18.75,1.5 L2.25,1.5 C1.83578644,1.5 1.5,1.83578644 1.5,2.25 L1.5,10.5 L19.5,10.5 Z" id="Shape"></path> + <path d="M2.46989185,24.0000019 C2.0824202,23.9978148 1.71179541,23.8300418 1.44030147,23.533929 C1.06505536,23.1372438 0.915224003,22.5566605 1.04698599,22.0098663 L2.16718829,17.2126415 C2.33099615,16.499659 2.928474,15.9988832 3.6139918,16.0000019 L17.3840163,16.0000019 C18.0690829,16.0002351 18.6664173,16.4989802 18.8338071,17.2105084 L19.9540094,22.0109328 C20.0515914,22.4236776 19.992843,22.8534867 19.7877215,23.220371 C19.5194216,23.7041349 19.0325398,24.0001624 18.5072059,23.9989335 L2.46989185,24.0000019 L2.46989185,24.0000019 Z M2.49478523,22.4002141 L18.5052144,22.4002141 L17.3850121,17.6008562 L3.61498753,17.5997897 L2.49478523,22.4002141 Z" id="Shape"></path> + <path d="M4.75,21 C4.33578644,21 4,20.5522847 4,20 C4,19.4477153 4.33578644,19 4.75,19 L6.25,19 C6.66421356,19 7,19.4477153 7,20 C7,20.5522847 6.66421356,21 6.25,21 L4.75,21 Z" id="Path"></path> + <path d="M13.75,21 C13.3357864,21 13,20.5522847 13,20 C13,19.4477153 13.3357864,19 13.75,19 L15.25,19 C15.6642136,19 16,19.4477153 16,20 C16,20.5522847 15.6642136,21 15.25,21 L13.75,21 Z" id="Path"></path> + <path d="M9.75,21 C9.33578644,21 9,20.5522847 9,20 C9,19.4477153 9.33578644,19 9.75,19 L11.25,19 C11.6642136,19 12,19.4477153 12,20 C12,20.5522847 11.6642136,21 11.25,21 L9.75,21 Z" id="Path"></path> + </g> + </g> + </g> + </g> + </g> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/images/icon-copy.png b/src/assets/images/icon-copy.png new file mode 100644 index 0000000..95064c0 Binary files /dev/null and b/src/assets/images/icon-copy.png differ diff --git a/src/assets/images/icon-dollar-circled.png b/src/assets/images/icon-dollar-circled.png new file mode 100644 index 0000000..46af102 Binary files /dev/null and b/src/assets/images/icon-dollar-circled.png differ diff --git a/src/assets/images/icon-link.png b/src/assets/images/icon-link.png new file mode 100644 index 0000000..3e09cca Binary files /dev/null and b/src/assets/images/icon-link.png differ diff --git a/src/assets/images/icon-open-outside.png b/src/assets/images/icon-open-outside.png new file mode 100644 index 0000000..743c8e0 Binary files /dev/null and b/src/assets/images/icon-open-outside.png differ diff --git a/src/components/Button/index.jsx b/src/components/Button/index.jsx index f0919ac..a9ca78b 100644 --- a/src/components/Button/index.jsx +++ b/src/components/Button/index.jsx @@ -10,6 +10,7 @@ import styles from "./styles.module.scss"; * @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 {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 @@ -24,6 +25,7 @@ const Button = ({ children, className, color = "primary", + isDisabled = false, isSelected = false, name, onClick, @@ -35,6 +37,7 @@ const Button = ({ }) => ( <button data-value={value} + disabled={isDisabled} name={name || ""} type={type} className={cn( @@ -58,6 +61,7 @@ Button.propTypes = { children: PT.node, className: PT.string, color: PT.oneOf(["primary"]), + isDisabled: PT.bool, isSelected: PT.bool, name: PT.string, onClick: PT.func, diff --git a/src/components/Button/styles.module.scss b/src/components/Button/styles.module.scss index 14164c5..2ae6cd1 100644 --- a/src/components/Button/styles.module.scss +++ b/src/components/Button/styles.module.scss @@ -10,6 +10,11 @@ text-transform: uppercase; outline: none; cursor: pointer; + + &:disabled { + opacity: 1; + cursor: not-allowed; + } } .medium { @@ -55,6 +60,12 @@ border-color: $primary-dark-color; color: $primary-dark-text-color; } + + &:disabled { + border-color: $control-disabled-border-color; + background-color: $control-disabled-bg-color; + color: $control-disabled-text-color; + } } .contained { @@ -76,6 +87,12 @@ border-color: $primary-dark-color; background-color: $primary-dark-color; } + + &:disabled { + border-color: $control-disabled-border-color; + background-color: $control-disabled-bg-color; + color: $control-disabled-text-color; + } } .circle, diff --git a/src/components/Checkbox/index.jsx b/src/components/Checkbox/index.jsx index 67ac06c..da21e84 100644 --- a/src/components/Checkbox/index.jsx +++ b/src/components/Checkbox/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import cn from "classnames"; +import { stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; /** @@ -9,20 +10,25 @@ 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 {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 * @param {Object} [props.option] object { value, label } * @param {'medium'|'small'} [props.size] checkbox size + * @param {boolean} [props.stopClickPropagation] whether to stop click event propagation * @returns {JSX.Element} */ const Checkbox = ({ checked, className, + isDisabled = false, name, onChange, option, size = "medium", + stopClickPropagation = false, }) => ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions <label className={cn( styles.container, @@ -30,9 +36,11 @@ const Checkbox = ({ { [styles.single]: !option || !option.label }, className )} + onClick={stopClickPropagation ? stopPropagation : null} > <input type="checkbox" + disabled={isDisabled} className={styles.checkbox} name={name} onChange={onChange} @@ -49,6 +57,7 @@ const Checkbox = ({ Checkbox.propTypes = { checked: PT.bool, className: PT.string, + isDisabled: PT.bool, name: PT.string.isRequired, size: PT.oneOf(["medium", "small"]), onChange: PT.func.isRequired, @@ -56,6 +65,7 @@ Checkbox.propTypes = { value: PT.string.isRequired, label: PT.string, }), + stopClickPropagation: PT.bool, }; export default Checkbox; diff --git a/src/components/Checkbox/styles.module.scss b/src/components/Checkbox/styles.module.scss index 10ff101..e07c408 100644 --- a/src/components/Checkbox/styles.module.scss +++ b/src/components/Checkbox/styles.module.scss @@ -36,6 +36,13 @@ input.checkbox { } } } + + &:disabled { + + .impostor { + background-color: $control-disabled-bg-color; + cursor: not-allowed; + } + } } .impostor { diff --git a/src/components/IntegerField/index.jsx b/src/components/IntegerField/index.jsx index c7a0684..0af814f 100644 --- a/src/components/IntegerField/index.jsx +++ b/src/components/IntegerField/index.jsx @@ -8,6 +8,7 @@ import styles from "./styles.module.scss"; * * @param {Object} props component properties * @param {string} [props.className] class name to be added to root element + * @param {boolean} [props.isDisabled] if the field is disabled * @param {string} props.name field's name * @param {number} props.value field's value * @param {number} [props.maxValue] maximum allowed value @@ -17,6 +18,7 @@ import styles from "./styles.module.scss"; */ const IntegerField = ({ className, + isDisabled = false, name, onChange, value, @@ -24,20 +26,37 @@ const IntegerField = ({ minValue = -Infinity, }) => ( <div className={cn(styles.container, className)}> + <input + disabled={isDisabled} + readOnly + className={styles.input} + name={name} + value={value} + /> <button className={styles.btnMinus} - onClick={() => onChange(Math.max(value - 1, minValue))} + onClick={(event) => { + event.stopPropagation(); + if (!isDisabled) { + onChange(Math.max(value - 1, minValue)); + } + }} /> <button className={styles.btnPlus} - onClick={() => onChange(Math.min(+value + 1, maxValue))} + onClick={(event) => { + event.stopPropagation(); + if (!isDisabled) { + onChange(Math.min(+value + 1, maxValue)); + } + }} /> - <input readOnly className={styles.input} name={name} value={value} /> </div> ); IntegerField.propTypes = { className: PT.string, + isDisabled: PT.bool, name: PT.string.isRequired, maxValue: PT.number, minValue: PT.number, diff --git a/src/components/IntegerField/styles.module.scss b/src/components/IntegerField/styles.module.scss index da7da79..32cd095 100644 --- a/src/components/IntegerField/styles.module.scss +++ b/src/components/IntegerField/styles.module.scss @@ -19,6 +19,18 @@ input.input { 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; + } + } } .btnMinus, diff --git a/src/components/Page/index.jsx b/src/components/Page/index.jsx index 4e681d9..2a85959 100644 --- a/src/components/Page/index.jsx +++ b/src/components/Page/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import cn from "classnames"; +import ReduxToastr from "react-redux-toastr"; import styles from "./styles.module.scss"; /** @@ -12,7 +13,15 @@ import styles from "./styles.module.scss"; * @returns {Object} */ const Page = ({ className, children }) => ( - <div className={cn(styles.container, className)}>{children}</div> + <div className={cn(styles.container, className)}> + {children} + <ReduxToastr + timeOut={60000} + position="top-right" + transitionIn="fadeIn" + transitionOut="fadeOut" + /> + </div> ); Page.propTypes = { diff --git a/src/components/SearchHandleField/index.jsx b/src/components/SearchHandleField/index.jsx new file mode 100644 index 0000000..38a2168 --- /dev/null +++ b/src/components/SearchHandleField/index.jsx @@ -0,0 +1,102 @@ +import React, { useCallback, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import _ from "lodash"; +import AsyncSelect from "react-select/async"; +import { getMemberSuggestions } from "services/teams"; +// import { getOptionByValue } from "utils/misc"; +import styles from "./styles.module.scss"; + +const selectComponents = { + DropdownIndicator: () => null, + IndicatorSeparator: () => null, +}; + +/** + * Displays search input field. + * + * @param {Object} props component properties + * @param {string} [props.className] class name added to root element + * @param {string} props.id id for input element + * @param {string} props.placeholder placeholder text + * @param {string} props.name name for input element + * @param {'medium'|'small'} [props.size] field size + * @param {function} props.onChange function called when input value changes + * @param {string} props.value input value + * @returns {JSX.Element} + */ +const SearchAutocomplete = ({ + className, + id, + size = "medium", + onChange, + placeholder, + value, +}) => { + // const option = getOptionByValue(options, value); + const [savedInput, setSavedInput] = useState(""); + + const onValueChange = useCallback( + (option) => { + onChange(option.value); + }, + [onChange] + ); + + return ( + <div className={cn(styles.container, styles[size], className)}> + <span className={styles.icon} /> + <AsyncSelect + className={styles.select} + classNamePrefix="custom" + components={selectComponents} + id={id} + isSearchable={true} + // menuIsOpen={true} // for debugging + // onChange={onOptionChange} + // onMenuOpen={onMenuOpen} + // onMenuClose={onMenuClose} + value={{ value, label: value }} + onInputChange={setSavedInput} + onFocus={() => { + setSavedInput(""); + onChange(savedInput); + }} + placeholder={placeholder} + onChange={onValueChange} + noOptionsMessage={() => "No options"} + loadingMessage={() => "Loading..."} + loadOptions={loadSuggestions} + blurInputOnSelect + /> + </div> + ); +}; + +const loadSuggestions = (inputVal) => { + return getMemberSuggestions(inputVal) + .then((res) => { + const users = _.get(res, "data.result.content", []); + return users.map((user) => ({ + label: user.handle, + value: user.handle, + })); + }) + .catch(() => { + console.warn("could not get suggestions"); + return []; + }); +}; + +SearchAutocomplete.propTypes = { + className: PT.string, + id: PT.string.isRequired, + size: PT.oneOf(["medium", "small"]), + name: PT.string.isRequired, + onChange: PT.func.isRequired, + options: PT.array, + placeholder: PT.string, + value: PT.oneOfType([PT.number, PT.string]), +}; + +export default SearchAutocomplete; diff --git a/src/components/SearchHandleField/styles.module.scss b/src/components/SearchHandleField/styles.module.scss new file mode 100644 index 0000000..e6614cc --- /dev/null +++ b/src/components/SearchHandleField/styles.module.scss @@ -0,0 +1,148 @@ +@import "styles/variables"; +@import "styles/mixins"; + +.container { + display: flex; + align-items: center; + border: 1px solid $control-border-color; + border-radius: 6px; + background-color: #fff; + + &.medium { + height: $control-height-medium; + } + + &.small { + height: $control-height-small; + } +} + +.icon { + margin: auto 10px; + width: 16px; + height: 16px; + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; + background-image: url("./../../assets/images/icon-magnifier.svg"); +} + +input.input { + &::placeholder { + text-transform: none; + color: #aaa; + } +} + +.select { + z-index: 1; + position: relative; + flex: 1 1 0; + align-self: stretch; + display: flex; + margin: 0; + border: none !important; + padding: 8px 16px 8px 0; + line-height: 22px; + background: none; + outline: none !important; + box-shadow: none !important; + + :global(.custom__control) { + flex: 1 1 0; + display: flex; + border: none; + // border: 1px solid $control-border-color; + // border-radius: 6px; + min-height: 0; + } + + :global(.custom__control--is-focused) { + box-shadow: none; + } + + :global(.custom__value-container) { + position: relative; + flex: 1 1 0; + align-self: stretch; + margin: 0; + border: none; + padding: 0; + + > * { + display: flex; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: 0; + border: none; + padding: 0; + } + + input { + flex: 1 1 0; + margin: 0 !important; + padding: 0 !important; + border: none !important; + width: auto !important; + height: 22px !important; + outline: none !important; + box-shadow: none !important; + line-height: 22px; + color: inherit; + } + } + + :global(.custom__single-value) { + line-height: 22px; + transform: none; + color: inherit; + } + + :global(.custom__input) { + flex: 1 1 0; + display: flex; + } + + :global(.custom__placeholder) { + + div { + margin: 0; + padding: 0; + color: inherit; + } + } + + :global(.custom__menu) { + margin: 1px 0 0; + border: 1px solid $control-border-color; + border-radius: 0; + box-shadow: none; + } + + :global(.custom__menu-list) { + padding: 9px 0; + } + + :global(.custom__option) { + padding: 0 20px 1px; + font-size: 16px; + line-height: 26px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background-color: #fff; + cursor: pointer; + @include roboto-regular; + + &:hover { + background-color: #f5f5f5; + } + } + + :global(.custom__option--is-selected) { + background-color: #229174 !important; + color: #fff; + } +} diff --git a/src/components/SelectField/index.jsx b/src/components/SelectField/index.jsx index 31d8587..99c1e26 100644 --- a/src/components/SelectField/index.jsx +++ b/src/components/SelectField/index.jsx @@ -23,6 +23,7 @@ const selectComponents = { DropdownIndicator, IndicatorSeparator: () => null }; * @param {Object} props component properties * @param {string} [props.className] class name to be added to root element * @param {string} props.id control's id + * @param {boolean} [props.isDisabled] whether the control should be disabled * @param {string} [props.label] control's label * @param {(v: string) => void} props.onChange on change handler * @param {Object} props.options options for dropdown @@ -33,6 +34,7 @@ const selectComponents = { DropdownIndicator, IndicatorSeparator: () => null }; const SelectField = ({ className, id, + isDisabled = false, label, onChange, options, @@ -76,6 +78,7 @@ const SelectField = ({ classNamePrefix="custom" components={selectComponents} id={id} + isDisabled={isDisabled} isSearchable={false} // menuIsOpen={true} // for debugging onChange={onOptionChange} @@ -91,6 +94,7 @@ const SelectField = ({ SelectField.propTypes = { className: PT.string, id: PT.string.isRequired, + isDisabled: PT.bool, label: PT.string, size: PT.oneOf(["medium", "small"]), onChange: PT.func.isRequired, diff --git a/src/components/ToastrMessage/index.jsx b/src/components/ToastrMessage/index.jsx new file mode 100644 index 0000000..32aa210 --- /dev/null +++ b/src/components/ToastrMessage/index.jsx @@ -0,0 +1,40 @@ +import React from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import styles from "./styles.module.scss"; + +/** + * Displays toastr message. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const ToastrMessage = ({ + children, + className, + message, + remove, + type = "info", +}) => { + return ( + <div className={cn(styles.container, styles[type], className)}> + {message && <span className={styles.message}>{message}</span>} + {children} + <button + type="button" + className={styles.btnClose} + onClick={remove} + ></button> + </div> + ); +}; + +ToastrMessage.propTypes = { + children: PT.node, + className: PT.string, + message: PT.string, + remove: PT.func, + type: PT.oneOf(["info", "success", "warning", "error"]), +}; + +export default ToastrMessage; diff --git a/src/components/ToastrMessage/styles.module.scss b/src/components/ToastrMessage/styles.module.scss new file mode 100644 index 0000000..606e378 --- /dev/null +++ b/src/components/ToastrMessage/styles.module.scss @@ -0,0 +1,72 @@ +.container { + display: block; + position: relative; + border-radius: 8px; + padding: 14px 27px 15px 64px; + font-size: 16px; + line-height: 26px; + text-align: center; + color: #fff; + + a { + color: #fff; + } +} + +.message { + text-align: center; +} + +.btnClose { + position: absolute; + top: 22px; + right: 27px; + margin: 0; + border: none; + padding: 0; + display: inline-block; + width: 10px; + height: 10px; + background: transparent; + outline: none !important; + cursor: pointer; + + &::before, + &::after { + content: ""; + display: block; + position: absolute; + top: 50%; + left: 0; + right: 0; + margin: -1px -1px auto; + width: 12px; + height: 2px; + background: #fff; + transform-origin: 50% 50%; + } + + &::before { + transform: rotate(45deg); + } + + &::after { + transform: rotate(-45deg); + } +} + +.info { + background: #1e94a3; +} + +.success { + background: #229174; +} + +.warning { + background: #ef476f; +} + +.error { + background: #e90c5a; +} diff --git a/src/components/Toggle/index.jsx b/src/components/Toggle/index.jsx new file mode 100644 index 0000000..59dc92c --- /dev/null +++ b/src/components/Toggle/index.jsx @@ -0,0 +1,51 @@ +import React, { useCallback } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import styles from "./styles.module.scss"; + +/** + * Displays a toggle. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {string} [props.id] id for toggle's input element + * @param {'medium'|'small'} [props.size] toggle size + * @param {boolean} props.isOn whether toggle is on or off + * @param {string} props.name name for toggle's input element + * @param {(v: boolean) => void} props.onChange function called with toggle's state changes + * @returns {JSX.Element} + */ +const Toggle = ({ className, id, isOn, name, onChange, size = "medium" }) => { + id = id || name; + + const onToggleChange = useCallback( + (event) => { + onChange(event.currentTarget.checked); + }, + [onChange] + ); + + return ( + <label htmlFor={id} className={cn(styles.toggle, styles[size], className)}> + <input + type="checkbox" + id={id} + name={name} + checked={isOn} + onChange={onToggleChange} + /> + <span /> + </label> + ); +}; + +Toggle.propTypes = { + className: PT.string, + id: PT.string, + isOn: PT.bool.isRequired, + name: PT.string.isRequired, + onChange: PT.func.isRequired, + size: PT.oneOf(["medium", "small"]), +}; + +export default Toggle; diff --git a/src/components/Toggle/styles.module.scss b/src/components/Toggle/styles.module.scss new file mode 100644 index 0000000..a789aaf --- /dev/null +++ b/src/components/Toggle/styles.module.scss @@ -0,0 +1,105 @@ +@import "styles/variables"; + +.toggle { + $toggle-width: 42px; + $toggle-height: 27px; + $handle-height: 21px; + $handle-width: $handle-height; + $handle-margin: ($toggle-height - $handle-height) / 2; + $handle-shift: $toggle-width - 2 * $handle-margin - $handle-width; + + position: relative; + display: inline-block; + width: $toggle-width; + height: $toggle-height; + border-radius: 500rem; + box-shadow: inset 0 1px 2px 0 rgba(0, 0, 0, 0.15); + overflow: hidden; + + input { + display: block; + z-index: 1; + position: relative; + margin: 0; + border: none; + padding: 0; + width: 100%; + height: 100%; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + &:checked { + + span { + background-color: $primary-light-color; + + &::after { + transform: translateX($handle-shift); + } + } + } + } + + span { + display: block; + z-index: 2; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + background-color: #aaa; + transition: background-color 0.3s ease-in-out; + cursor: pointer; + + &::after { + content: ""; + display: block; + position: absolute; + left: $handle-margin; + top: 0; + bottom: 0; + margin: auto 0; + width: $handle-width; + height: $handle-height; + border-radius: 500rem; + background-color: #fff; + box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.35); + transform: translateX(0); + transition: transform 0.3s ease-in-out; + // overflow: hidden; + } + } + + &.small { + $toggle-width: 34px; + $toggle-height: 20px; + $handle-height: 14px; + $handle-width: $handle-height; + $handle-margin: ($toggle-height - $handle-height) / 2; + $handle-shift: $toggle-width - 2 * $handle-margin - $handle-width; + + width: $toggle-width; + height: $toggle-height; + + input { + &:checked { + + span { + &::after { + transform: translateX($handle-shift); + } + } + } + } + + span { + &::after { + left: $handle-margin; + width: $handle-width; + height: $handle-height; + } + } + } +} diff --git a/src/constants/index.js b/src/constants/index.js index 94326c5..51f91e6 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -1,6 +1,6 @@ -import { PLATFORM_WEBSITE_URL } from "../../config"; +import { PLATFORM_WEBSITE_URL, TOPCODER_WEBSITE_URL } from "../../config"; -export { PLATFORM_WEBSITE_URL }; +export { PLATFORM_WEBSITE_URL, TOPCODER_WEBSITE_URL }; export const APP_BASE_PATH = "/taas-admin"; diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index fc1492c..5de195d 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -8,15 +8,22 @@ import * as PAYMENT_STATUS from "./workPeriods/paymentStatus"; export { API_PAYMENT_STATUS, API_SORT_BY, SORT_BY, SORT_ORDER, PAYMENT_STATUS }; -// challenges API url -export const API_URL = `${API.V5}/resourceBookings`; +// resource bookings API url +export const RB_API_URL = `${API.V5}/resourceBookings`; +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 DATE_FORMAT = "YYYY-MM-DD"; +export const DATE_FORMAT_API = "YYYY-MM-DD"; +export const DATE_FORMAT_UI = "MMM DD, YYYY"; -// Field names that are required to be retrieved for display, filtering and soring. +// Field names that are required to be retrieved for display, filtering and sorting. export const REQUIRED_FIELDS = [ "id", + "jobId", "projectId", + "billingAccountId", "startDate", "endDate", "memberRate", @@ -59,7 +66,7 @@ export const PAYMENT_STATUS_LABELS = { [PAYMENT_STATUS.PAID]: "Paid", [PAYMENT_STATUS.PENDING]: "Pending", [PAYMENT_STATUS.IN_PROGRESS]: "In Progress", - [PAYMENT_STATUS.UNDEFINED]: "Undefined", + [PAYMENT_STATUS.UNDEFINED]: "NA", }; export const PAYMENT_STATUS_MAP = { diff --git a/src/root.component.jsx b/src/root.component.jsx index 6be922c..9bec738 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -1,7 +1,6 @@ import React, { useLayoutEffect } from "react"; import { Provider } from "react-redux"; import { Router, Redirect } from "@reach/router"; -// import ReduxToastr from "react-redux-toastr"; import store from "store"; import { disableSidebarForRoute } from "@topcoder/micro-frontends-navbar-app"; import WorkPeriods from "routes/WorkPeriods"; @@ -25,12 +24,6 @@ export default function Root() { <WorkPeriods path={`${APP_BASE_PATH}/work-periods`} /> <Freelancers path={`${APP_BASE_PATH}/freelancers`} /> </Router> - {/* <ReduxToastr - timeOut={4000} - position="bottom-left" - transitionIn="fadeIn" - transitionOut="fadeOut" - /> */} </Provider> ); } diff --git a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss index 139f939..a232c95 100644 --- a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss @@ -30,5 +30,10 @@ } .undefined { - background: #2a2a2a; + padding: 0; + @include roboto-regular; + font-size: 14px; + line-height: 20px; + letter-spacing: normal; + background: transparent; } diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx new file mode 100644 index 0000000..1914614 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -0,0 +1,48 @@ +// @ts-nocheck +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import React, { useCallback, useRef } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import styles from "./styles.module.scss"; +import { formatChallengeUrl } from "utils/formatters"; + +const PaymentsListItem = ({ className, item }) => { + const inputRef = useRef(); + + const onCopyLinkClick = useCallback(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + document.execCommand("copy"); + }, []); + + return ( + <div className={cn(styles.container, className)}> + <span className={styles.iconLink}></span> + <input + readOnly + ref={inputRef} + type="text" + value={item.challengeId || "0"} + /> + <span className={styles.iconCopyLink} onClick={onCopyLinkClick}></span> + <a + className={styles.iconOpenLink} + href={formatChallengeUrl(item.challengeId)} + target="_blank" + rel="noreferrer" + > + <span className={styles.hidden}>{item.id}</span> + </a> + </div> + ); +}; + +PaymentsListItem.propTypes = { + className: PT.string, + item: PT.shape({ + id: PT.oneOfType([PT.string, PT.number]), + challengeId: PT.oneOfType([PT.string, PT.number]), + }), +}; + +export default PaymentsListItem; diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss new file mode 100644 index 0000000..b71ea11 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss @@ -0,0 +1,62 @@ +@import "styles/mixins"; + +.container { + display: flex; + align-items: center; + + input { + display: block; + margin: 0; + border: none; + padding: 0; + max-width: 70px; + height: 22px; + background: #fff; + @include roboto-medium; + line-height: 22px; + outline: none !important; + color: #0d61bf; + } +} + +.iconLink { + flex: 0 0 auto; + display: inline-block; + margin-right: 5px; + width: 16px; + height: 16px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + background-image: url("./../../../../assets/images/icon-link.png"); +} + +.iconCopyLink { + flex: 0 0 auto; + display: inline-block; + margin-left: 18px; + width: 16px; + height: 16px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + background-image: url("./../../../../assets/images/icon-copy.png"); + cursor: pointer; +} + +.iconOpenLink { + flex: 0 0 auto; + display: inline-block; + margin-left: 16px; + width: 16px; + height: 16px; + text-decoration: none; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + background-image: url("./../../../../assets/images/icon-open-outside.png"); +} + +.hidden { + display: none; +} diff --git a/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx b/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx new file mode 100644 index 0000000..93e8324 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx @@ -0,0 +1,36 @@ +import React from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import styles from "./styles.module.scss"; +import PaymentsListItem from "../PaymentsListItem"; + +/** + * Displays popup with payments. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const PaymentsPopup = ({ className, payments }) => { + return ( + <form className={cn(styles.container, className)} action="#"> + <div className={styles.title}>Challenges for Payments</div> + <div className={styles.paymentsList}> + {payments.map((payment) => ( + <PaymentsListItem key={payment.id} item={payment} /> + ))} + </div> + </form> + ); +}; + +PaymentsPopup.propTypes = { + className: PT.string, + payments: PT.arrayOf( + PT.shape({ + id: PT.oneOfType([PT.string, PT.number]), + challengeId: PT.oneOfType([PT.string, PT.number]), + }) + ), +}; + +export default PaymentsPopup; diff --git a/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss new file mode 100644 index 0000000..a29e703 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss @@ -0,0 +1,20 @@ +@import "styles/mixins"; + +.container { + position: relative; + border-radius: 8px; + padding: 25px 30px 25px 23px; + box-shadow: 0 5px 35px 5px rgba(21, 21, 22, 0.1), + 0 10px 14px 0 rgba(21, 21, 22, 0.3); + background: #fff; +} + +.title { + @include roboto-medium; + line-height: 20px; + white-space: nowrap; +} + +.paymentsList { + margin-top: 5px; +} diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx new file mode 100644 index 0000000..bf0ce72 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -0,0 +1,204 @@ +import React, { memo, useCallback } from "react"; +import { useDispatch } from "react-redux"; +import PT from "prop-types"; +import cn from "classnames"; +import debounce from "lodash/debounce"; +import Button from "components/Button"; +import Toggle from "components/Toggle"; +import SelectField from "components/SelectField"; +import PeriodsHistory from "../PeriodsHistory"; +import IconComputer from "../../../../assets/images/icon-computer.svg"; +import { + hideWorkPeriodDetails, + setBillingAccount, + setDetailsHidePastPeriods, + setDetailsLockWorkingDays, +} from "store/actions/workPeriods"; +import styles from "./styles.module.scss"; +import { updateWorkPeriodBillingAccount } from "store/thunks/workPeriods"; +import { useUpdateEffect } from "utils/hooks"; + +/** + * Displays working period details. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {Object} props.details working period details object + * @param {boolean} props.isDisabled whether the details are disabled + * @returns {JSX.Element} + */ +const PeriodDetails = ({ className, details, isDisabled }) => { + const dispatch = useDispatch(); + const { + periodId, + rbId, + jobName, + jobNameIsLoading, + billingAccountId, + billingAccounts, + billingAccountsIsLoading, + periodsVisible, + periodsIsLoading, + hidePastPeriods, + lockWorkingDays, + } = details; + + const onHideDetailsBtnClick = useCallback(() => { + dispatch(hideWorkPeriodDetails(periodId)); + }, [dispatch, periodId]); + + const onChangeHidePastPeriods = useCallback( + (hide) => { + dispatch(setDetailsHidePastPeriods(periodId, hide)); + }, + [dispatch, periodId] + ); + + const onChangeLockWorkingDays = useCallback( + (lock) => { + dispatch(setDetailsLockWorkingDays(periodId, lock)); + }, + [dispatch, periodId] + ); + + const onChangeBillingAccount = useCallback( + (value) => { + dispatch(setBillingAccount(periodId, value)); + }, + [dispatch, periodId] + ); + + const updateBillingAccount = useCallback( + debounce( + (billingAccountId) => { + dispatch(updateWorkPeriodBillingAccount(rbId, billingAccountId)); + }, + 300, + { leading: false } + ), + [dispatch, rbId] + ); + + useUpdateEffect(() => { + updateBillingAccount(billingAccountId); + }, [billingAccountId]); + + const isFailedLoadingJobName = !jobNameIsLoading && jobName === "Error"; + const isFailedLoadingBilAccs = + !billingAccountsIsLoading && + billingAccounts.length === 1 && + billingAccounts[0].value === 0; + const isDisabledBilAccs = + !billingAccountsIsLoading && + billingAccounts.length === 1 && + billingAccounts[0].value === -1; + + return ( + <tr className={cn(styles.container, className)}> + {periodsIsLoading ? ( + <td colSpan={8}> + <div className={styles.loadingIndicator}>Loading...</div> + </td> + ) : ( + <> + <td colSpan={3} className={styles.periodInfo}> + <div className={styles.jobNameSection}> + <IconComputer className={styles.jobNameIcon} /> + <div className={styles.sectionField}> + <div className={styles.label}>Job Name</div> + <div + className={cn(styles.jobName, { + [styles.jobNameError]: isFailedLoadingJobName, + })} + > + {jobNameIsLoading ? "Loading..." : jobName} + </div> + </div> + </div> + <div className={styles.lockWorkingDaysSection}> + <div className={styles.sectionLabel}>Lock Working Days</div> + <Toggle + size="small" + className={styles.lockWorkingDaysToggle} + name={`rb_lck_wd_${periodId}`} + onChange={onChangeLockWorkingDays} + isOn={lockWorkingDays} + /> + </div> + <div className={styles.billingAccountSection}> + <div className={styles.sectionLabel}>Billing Account</div> + <SelectField + className={ + isFailedLoadingBilAccs ? styles.billingAccountError : "" + } + id={`rb_bil_acc_${periodId}`} + isDisabled={isDisabledBilAccs} + size="small" + onChange={onChangeBillingAccount} + options={billingAccounts} + value={billingAccountId} + /> + </div> + <div className={styles.detailsControls}> + <Button size="small" onClick={onHideDetailsBtnClick}> + Hide Details + </Button> + </div> + </td> + <td colSpan={5} className={styles.periodHistory}> + <div className={styles.periodsContainer}> + <div className={styles.periodsHeader}> + <span className={styles.periodsHeaderTitle}>History</span> + <span className={styles.hidePastPeriods}> + <label + htmlFor={`hide_past_wp_${periodId}`} + className={styles.hidePastPeriodsLabel} + > + Current & Future Only + </label> + <Toggle + className={styles.hidePastPeriodsToggle} + name={`hide_past_wp_${periodId}`} + onChange={onChangeHidePastPeriods} + size="small" + isOn={hidePastPeriods} + /> + </span> + </div> + <PeriodsHistory + isDisabled={isDisabled} + periodId={periodId} + periods={periodsVisible} + /> + </div> + </td> + </> + )} + </tr> + ); +}; + +PeriodDetails.propTypes = { + className: PT.string, + details: PT.shape({ + periodId: PT.string.isRequired, + rbId: PT.string.isRequired, + jobName: PT.string, + jobNameIsLoading: PT.bool.isRequired, + billingAccountId: PT.number.isRequired, + billingAccounts: PT.arrayOf( + PT.shape({ + label: PT.string.isRequired, + value: PT.string.isRequired, + }) + ), + billingAccountsIsLoading: PT.bool.isRequired, + periodsVisible: PT.array.isRequired, + periodsIsLoading: PT.bool.isRequired, + hidePastPeriods: PT.bool.isRequired, + lockWorkingDays: PT.bool.isRequired, + }).isRequired, + isDisabled: PT.bool.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 new file mode 100644 index 0000000..6349a41 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodDetails/styles.module.scss @@ -0,0 +1,131 @@ +@import "styles/mixins"; + +.container { + position: relative; +} + +.loadingIndicator { + border: 1px solid #d6d6d6; + border-top: none; + padding: 31px 0 35px; + text-align: center; + @include roboto-regular; + font-size: 24px; + background-color: #fff; + color: #aaa; +} + +.periodInfo { + vertical-align: top; + border: 1px solid #d6d6d6; + border-top: none; + border-right: none; + padding: 16px 22px 13px; +} + +.label { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.5px; + color: #555; +} + +.sectionLabel { + @include roboto-medium; + font-size: 14px; + line-height: 26px; +} + +.jobNameSection { + display: flex; + align-items: center; +} + +.jobNameIcon { + flex: 0 0 auto; + margin-right: 16px; + width: 21px; + height: auto; +} + +.sectionField { + flex: 1 1 auto; +} + +.jobName { + margin-top: 2px; + @include roboto-medium; + line-height: 22px; +} + +.jobNameError { + color: #e90c5a; +} + +.lockWorkingDaysSection { + margin-top: 19px; +} + +.lockWorkingDaysToggle { + margin-top: 6px; +} + +.billingAccountSection { + margin-top: 13px; +} + +.billingAccountError { + color: #e90c5a; +} + +.detailsControls { + margin-top: 20px; +} + +.periodHistory { + vertical-align: top; + border: 1px solid #d6d6d6; + border-top: none; + border-left: none; +} + +.periodsContainer { + border: 1px solid #d6d6d6; + border-right: none; +} + +.periodsHeader { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + padding: 7px 12px; + background: #f4f4f4; + + &::after { + content: ""; + display: block; + position: absolute; + left: 12px; + right: 12px; + bottom: 0; + height: 1px; + background: #e9e9e9; + } +} + +.periodsHeaderTitle { + @include roboto-medium; + line-height: 26px; +} + +.hidePastPeriods { + display: flex; + align-items: center; + @include roboto-medium; + line-height: 26px; +} + +.hidePastPeriodsLabel { + margin-right: 7px; +} diff --git a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx index fff1eb6..695ed57 100644 --- a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx @@ -5,6 +5,7 @@ import PT from "prop-types"; import cn from "classnames"; // import SidebarSection from "components/SidebarSection"; import Button from "components/Button"; +import SearchHandleField from "components/SearchHandleField"; // import CheckboxList from "components/CheckboxList"; // import { PAYMENT_STATUS } from "constants/workPeriods"; import { getWorkPeriodsFilters } from "store/selectors/workPeriods"; @@ -16,7 +17,6 @@ import { import { loadWorkPeriodsPage as loadWorkingPeriodsPage } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; import styles from "./styles.module.scss"; -import SearchField from "components/SearchField"; /** * Displays working periods' filters like user handle search control or @@ -66,7 +66,7 @@ const PeriodFilters = ({ className }) => { return ( <form className={cn(styles.container, className)} action="#"> <div className={styles.handleSection}> - <SearchField + <SearchHandleField id="topcoder-handle" name="topcoder_handle" placeholder="Search Topcoder Handle" diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 4b1a943..92ca032 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -1,82 +1,136 @@ import React, { memo, useCallback } 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 PaymentStatus from "../PaymentStatus"; +import PeriodDetails from "../PeriodDetails"; +import { + setWorkPeriodWorkingDays, + toggleWorkPeriod, +} from "store/actions/workPeriods"; +import { + toggleWorkPeriodDetails, + updateWorkPeriodWorkingDays, +} from "store/thunks/workPeriods"; +import { useUpdateEffect } from "utils/hooks"; import { formatUserHandleLink, formatWeeklyRate } from "utils/formatters"; import styles from "./styles.module.scss"; +/** + * @param {(v: string) => void} props.onToggle function called when working period checkbox is clicked + * @param {(id: object) => void} props.onToggleDetails function called when item row is clicked + * @param {(v: { periodId: string, workingDays: number }) => void} props.onWorkingDaysChange + * function called when the number of working days is changed + */ + /** * 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.isSelected whether the item is selected * @param {Object} props.item object describing a working period - * @param {(v: string) => void} props.onToggle function called when working period checkbox is clicked - * @param {(v: { periodId: string, workingDays: number }) => void} props.onWorkingDaysChange - * function called when the number of working days is changed + * @param {Object} [props.details] object with working period details * @returns {JSX.Element} */ -const PeriodItem = ({ isSelected, item, onToggle, onWorkingDaysChange }) => { +const PeriodItem = ({ isDisabled = false, isSelected, item, details }) => { + const dispatch = useDispatch(); + const onToggleItem = useCallback( (event) => { - onToggle(event.target.value); + dispatch(toggleWorkPeriod(event.target.value)); }, - [onToggle] + [dispatch] ); - const onDaysChange = useCallback( + + const onToggleItemDetails = useCallback(() => { + dispatch(toggleWorkPeriodDetails(item)); + }, [dispatch, item]); + + const onWorkingDaysChange = useCallback( (workingDays) => { - onWorkingDaysChange({ periodId: item.id, workingDays }); + dispatch(setWorkPeriodWorkingDays({ periodId: item.id, workingDays })); }, - [item, onWorkingDaysChange] + [dispatch, item.id] ); + + const updateWorkingDays = useCallback( + debounce( + (workingDays) => { + dispatch(updateWorkPeriodWorkingDays(item.id, workingDays)); + }, + 300, + { leading: false } + ), + [dispatch, item.id] + ); + + // Update working days on server if working days change. + useUpdateEffect(() => { + updateWorkingDays(item.workingDays); + }, [item.workingDays]); + return ( - <tr className={styles.container}> - <td className={styles.toggle}> - <Checkbox - size="small" - checked={isSelected} - name={`res_chb_${item.id}`} - onChange={onToggleItem} - option={{ value: item.id }} - /> - </td> - <td className={styles.userHandle}> - <span> - <a - href={formatUserHandleLink(item.projectId, item.rbId)} - target="_blank" - rel="noreferrer" - > - {item.userHandle} - </a> - </span> - </td> - <td className={styles.teamName}>{item.projectId}</td> - <td className={styles.startDate}>{item.startDate}</td> - <td className={styles.endDate}>{item.endDate}</td> - <td className={styles.weeklyRate}> - <span>{formatWeeklyRate(item.weeklyRate)}</span> - </td> - <td> - <PaymentStatus status={item.paymentStatus} /> - </td> - <td className={styles.workingDays}> - <IntegerField - className={styles.workingDaysControl} - name={`res_wrk_days_${item.id}`} - onChange={onDaysChange} - maxValue={7} - minValue={0} - value={item.workingDays} - /> - </td> - </tr> + <> + <tr + className={cn(styles.container, { [styles.hasDetails]: !!details })} + onClick={onToggleItemDetails} + > + <td className={styles.toggle}> + <Checkbox + size="small" + isDisabled={isDisabled} + checked={isSelected} + name={`wp_chb_${item.id}`} + onChange={onToggleItem} + option={{ value: item.id }} + stopClickPropagation={true} + /> + </td> + <td className={styles.userHandle}> + <span> + <a + href={formatUserHandleLink(item.projectId, item.rbId)} + onClick={stopPropagation} + target="_blank" + rel="noreferrer" + > + {item.userHandle} + </a> + </span> + </td> + <td className={styles.teamName}>{item.projectId}</td> + <td className={styles.startDate}>{item.startDate}</td> + <td className={styles.endDate}>{item.endDate}</td> + <td className={styles.weeklyRate}> + <span>{formatWeeklyRate(item.weeklyRate)}</span> + </td> + <td> + <PaymentStatus status={item.paymentStatus} /> + </td> + <td className={styles.workingDays}> + <IntegerField + className={styles.workingDaysControl} + isDisabled={isDisabled} + name={`wp_wrk_days_${item.id}`} + onChange={onWorkingDaysChange} + maxValue={5} + minValue={0} + value={item.workingDays} + /> + </td> + </tr> + {details && <PeriodDetails details={details} isDisabled={isDisabled} />} + </> ); }; PeriodItem.propTypes = { className: PT.string, + isDisabled: PT.bool, isSelected: PT.bool.isRequired, item: PT.shape({ id: PT.oneOfType([PT.number, PT.string]).isRequired, @@ -90,8 +144,33 @@ PeriodItem.propTypes = { paymentStatus: PT.string.isRequired, workingDays: PT.number.isRequired, }), - onToggle: PT.func.isRequired, - onWorkingDaysChange: PT.func.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({ + value: PT.string.isRequired, + label: PT.string.isRequired, + }) + ), + billingAccountsIsLoading: PT.bool.isRequired, + periods: PT.arrayOf( + PT.shape({ + id: PT.string.isRequired, + }) + ), + periodsIsLoading: PT.bool.isRequired, + }), + // onToggle: PT.func.isRequired, + // onToggleDetails: PT.func.isRequired, + // onWorkingDaysChange: PT.func.isRequired, }; +function stopPropagation(event) { + event.stopPropagation(); +} + 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 18dd70c..98a4561 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss @@ -12,6 +12,25 @@ background: #f9f9f9; } } + + &.hasDetails { + td { + border-top: 1px solid #d6d6d6; + background: #fff; + + &.toggle { + border-left: 1px solid #d6d6d6; + padding-top: 11px; + padding-left: 14px; + } + + &.workingDays { + border-right: 1px solid #d6d6d6; + padding-top: 4px; + padding-right: 9px; + } + } + } } td.toggle { diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index 5cb6f88..e925a27 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -1,17 +1,15 @@ -import React, { useCallback } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import React from "react"; +import { useSelector } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; import PeriodItem from "../PeriodItem"; import PeriodListHead from "../PeriodListHead"; import { getWorkPeriods, + getWorkPeriodsDetails, + getWorkPeriodsIsProcessingPayments, getWorkPeriodsSelected, } from "store/selectors/workPeriods"; -import { - setWorkPeriodWorkingDays, - toggleWorkPeriod, -} from "store/actions/workPeriods"; import styles from "./styles.module.scss"; /** @@ -23,22 +21,9 @@ import styles from "./styles.module.scss"; */ const PeriodList = ({ className }) => { const periods = useSelector(getWorkPeriods); + const periodsDetails = useSelector(getWorkPeriodsDetails); const periodsSelected = useSelector(getWorkPeriodsSelected); - const dispatch = useDispatch(); - - const onTogglePeriod = useCallback( - (periodId) => { - dispatch(toggleWorkPeriod(periodId)); - }, - [dispatch] - ); - - const onWorkingDaysChange = useCallback( - (payload) => { - dispatch(setWorkPeriodWorkingDays(payload)); - }, - [dispatch] - ); + const isProcessingPayments = useSelector(getWorkPeriodsIsProcessingPayments); return ( <div className={cn(styles.container, className)}> @@ -53,10 +38,10 @@ const PeriodList = ({ className }) => { {periods.map((period) => ( <PeriodItem key={period.id} + isDisabled={isProcessingPayments} isSelected={period.id in periodsSelected} item={period} - onToggle={onTogglePeriod} - onWorkingDaysChange={onWorkingDaysChange} + details={periodsDetails[period.id]} /> ))} </tbody> diff --git a/src/routes/WorkPeriods/components/PeriodsContentHeader/index.jsx b/src/routes/WorkPeriods/components/PeriodsContentHeader/index.jsx new file mode 100644 index 0000000..36c4417 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsContentHeader/index.jsx @@ -0,0 +1,36 @@ +import React, { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import ContentHeader from "components/ContentHeader"; +import Button from "components/Button"; +import PageTitle from "components/PageTitle"; +import { + getWorkPeriodsHasSelectedItems, + getWorkPeriodsIsProcessingPayments, +} from "store/selectors/workPeriods"; +import { processPayments } from "store/thunks/workPeriods"; +import styles from "./styles.module.scss"; + +const PeriodsContentHeader = () => { + const hasSelectedItems = useSelector(getWorkPeriodsHasSelectedItems); + const isProcessingPayments = useSelector(getWorkPeriodsIsProcessingPayments); + const dispatch = useDispatch(); + + const onProcessPaymentsClick = useCallback(() => { + dispatch(processPayments); + }, [dispatch]); + + return ( + <ContentHeader className={styles.container}> + <PageTitle text="Working Periods" /> + <Button + variant="contained" + isDisabled={!hasSelectedItems || isProcessingPayments} + onClick={onProcessPaymentsClick} + > + Process Payment + </Button> + </ContentHeader> + ); +}; + +export default PeriodsContentHeader; diff --git a/src/routes/WorkPeriods/components/PeriodsContentHeader/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsContentHeader/styles.module.scss new file mode 100644 index 0000000..b5709b1 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsContentHeader/styles.module.scss @@ -0,0 +1,6 @@ +.container { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx new file mode 100644 index 0000000..ad283bf --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import PT from "prop-types"; +import cn from "classnames"; +import PeriodHistoryItem from "../PeriodsHistoryItem"; +import { getWorkPeriodsDateRange } from "store/selectors/workPeriods"; +import styles from "./styles.module.scss"; + +/** + * Displays all working periods' history. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const PeriodsHistory = ({ className, isDisabled, periodId, periods }) => { + const [startDate] = useSelector(getWorkPeriodsDateRange); + return ( + <div className={cn(styles.container, className)}> + <table> + {periods.map((period) => ( + <PeriodHistoryItem + key={period.id} + periodId={periodId} + isDisabled={isDisabled} + item={period} + currentStartDate={startDate} + /> + ))} + </table> + </div> + ); +}; + +PeriodsHistory.propTypes = { + className: PT.string, + isDisabled: PT.bool.isRequired, + periodId: PT.string.isRequired, + periods: PT.arrayOf(PT.object), +}; + +export default PeriodsHistory; diff --git a/src/routes/WorkPeriods/components/PeriodsHistory/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsHistory/styles.module.scss new file mode 100644 index 0000000..4f96674 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsHistory/styles.module.scss @@ -0,0 +1,11 @@ +.container { + position: relative; + height: 279px; + overflow-x: hidden; + overflow-y: auto; + + > table { + width: 100%; + border-collapse: collapse; + } +} diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx new file mode 100644 index 0000000..8934dff --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx @@ -0,0 +1,114 @@ +import React, { memo, useCallback } from "react"; +import { useDispatch } from "react-redux"; +import PT from "prop-types"; +import cn from "classnames"; +import debounce from "lodash/debounce"; +import IntegerField from "components/IntegerField"; +import PaymentStatus from "../PaymentStatus"; +import { PAYMENT_STATUS } from "constants/workPeriods"; +import { setDetailsWorkingDays } from "store/actions/workPeriods"; +import { updateWorkPeriodWorkingDays } from "store/thunks/workPeriods"; +import { useUpdateEffect } from "utils/hooks"; +import { + formatDateLabel, + formatDateRange, + formatWeeklyRate, +} from "utils/formatters"; +import styles from "./styles.module.scss"; +import PeriodsHistoryWeeklyRate from "../PeriodsHistoryWeeklyRate"; + +/** + * Displays working period row in history table in details view. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const PeriodsHistoryItem = ({ + periodId, + isDisabled, + item, + currentStartDate, +}) => { + const dispatch = useDispatch(); + + const dateLabel = formatDateLabel(item.startDate, currentStartDate); + const workingDays = item.workingDays; + + const onWorkingDaysChange = useCallback( + (workingDays) => { + dispatch(setDetailsWorkingDays(periodId, item.id, workingDays)); + }, + [dispatch, periodId, item.id] + ); + + const updateWorkingDays = useCallback( + debounce( + (workingDays) => { + dispatch(updateWorkPeriodWorkingDays(item.id, workingDays)); + }, + 300, + { leading: false } + ), + [dispatch, item.id] + ); + + // Update working days on server if working days change. + useUpdateEffect(() => { + updateWorkingDays(item.workingDays); + }, [item.workingDays]); + + return ( + <tr + className={cn(styles.container, { + [styles.current]: dateLabel === "Current Period", + })} + > + <td className={styles.dateRange}> + {formatDateRange(item.startDate, item.endDate)} + </td> + <td className={styles.dateLabel}>{dateLabel}</td> + <td className={styles.weeklyRate}> + <PeriodsHistoryWeeklyRate + className={styles.weeklyRateContainer} + payments={item.payments} + weeklyRate={formatWeeklyRate(item.weeklyRate)} + /> + </td> + <td className={styles.paymentStatus}> + <PaymentStatus status={item.paymentStatus} /> + </td> + <td className={styles.workingDays}> + {item.paymentStatus === PAYMENT_STATUS.PAID ? ( + `${workingDays} ${workingDays === 1 ? "Day" : "Days"}` + ) : ( + <IntegerField + className={styles.workingDaysControl} + name={`wp_det_wd_${item.id}`} + isDisabled={isDisabled} + onChange={onWorkingDaysChange} + value={workingDays} + maxValue={5} + minValue={0} + /> + )} + </td> + </tr> + ); +}; + +PeriodsHistoryItem.propTypes = { + periodId: PT.string.isRequired, + 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, + paymentStatus: PT.string.isRequired, + payments: PT.array, + weeklyRate: PT.number.isRequired, + workingDays: PT.number.isRequired, + }).isRequired, + currentStartDate: PT.oneOfType([PT.string, PT.number, PT.object]).isRequired, +}; + +export default memo(PeriodsHistoryItem); diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss new file mode 100644 index 0000000..27d50dc --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss @@ -0,0 +1,46 @@ +.container { + position: relative; + + td { + border-top: 1px solid #e9e9e9; + } + + &:first-child { + td { + border-top: none; + } + } +} + +.dateRange { + padding: 8px 12px; +} + +.dateLabel { + padding: 8px 12px; +} + +.current { + .dateRange, + .dateLabel { + font-weight: 700; + } +} + +.weeklyRate { + padding: 6px 12px; + line-height: 26px; +} + +.weeklyRateContainer { + position: relative; +} + +.workingDays { + padding: 4px 10px; +} + +.workingDaysControl { + display: block; + width: 100px; +} diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryWeeklyRate/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryWeeklyRate/index.jsx new file mode 100644 index 0000000..8383ecc --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsHistoryWeeklyRate/index.jsx @@ -0,0 +1,78 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import React, { useCallback, useRef, useState } from "react"; +import { usePopper } from "react-popper"; +import PT from "prop-types"; +import cn from "classnames"; +import ChallengePopup from "../PaymentsPopup"; +import compStyles from "./styles.module.scss"; +import { useClickOutside } from "utils/hooks"; + +const PeriodsHistoryWeeklyRate = ({ className, payments, weeklyRate }) => { + const [isShowPopup, setIsShowPopup] = useState(false); + const containerRef = useRef(); + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom", + modifiers: [ + { name: "arrow", options: { element: arrowElement, padding: 10 } }, + { name: "offset", options: { offset: [0, 5] } }, + { name: "preventOverflow", options: { padding: 15 } }, + ], + }); + + const onWeeklyRateClick = useCallback(() => { + setIsShowPopup(negate); + }, []); + + const onClickOutside = useCallback(() => { + setIsShowPopup(false); + }, []); + + useClickOutside(containerRef, onClickOutside, []); + + const hasPayments = !!payments && !!payments.length; + + return ( + <div className={cn(compStyles.container, className)} ref={containerRef}> + <span + className={cn(compStyles.weeklyRateValue, { + [compStyles.hasPayments]: hasPayments, + })} + ref={setReferenceElement} + onClick={onWeeklyRateClick} + > + {weeklyRate} + </span> + {hasPayments && isShowPopup && ( + <div + className={compStyles.popup} + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + > + <ChallengePopup payments={payments} /> + <div + className="dropdown-arrow" + ref={setArrowElement} + style={styles.arrow} + /> + </div> + )} + </div> + ); +}; + +PeriodsHistoryWeeklyRate.propTypes = { + className: PT.string, + payments: PT.array, + weeklyRate: PT.string.isRequired, +}; + +function negate(value) { + return !value; +} + +export default PeriodsHistoryWeeklyRate; diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryWeeklyRate/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsHistoryWeeklyRate/styles.module.scss new file mode 100644 index 0000000..8ab6350 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsHistoryWeeklyRate/styles.module.scss @@ -0,0 +1,21 @@ +.container { + position: relative; + + .dropdown-arrow { + display: none; + } +} + +.weeklyRateValue { + display: inline-block; + width: 70px; + text-align: right; +} + +.hasPayments { + cursor: pointer; +} + +.popup { + z-index: 1; +} diff --git a/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx new file mode 100644 index 0000000..03578a8 --- /dev/null +++ b/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import PT from "prop-types"; +import ToastMessage from "components/ToastrMessage"; + +/** + * Displays a toastr message with info about the number of resources payments + * for which have been failed to be scheduled. + * + * @param {Object} props + * @returns {JSX.Element} + */ +const ToastPaymentsSuccess = ({ periods, remove }) => { + return ( + <ToastMessage type="error" remove={remove}> + Failed to schedule payments for {periods.length} resources + </ToastMessage> + ); +}; + +ToastPaymentsSuccess.propTypes = { + periods: PT.arrayOf( + PT.shape({ + workPeriodId: PT.string.isRequired, + amount: PT.number.isRequired, + }) + ), + remove: PT.func, +}; + +export default ToastPaymentsSuccess; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsError/styles.module.scss b/src/routes/WorkPeriods/components/ToastPaymentsError/styles.module.scss new file mode 100644 index 0000000..4867abe --- /dev/null +++ b/src/routes/WorkPeriods/components/ToastPaymentsError/styles.module.scss @@ -0,0 +1,3 @@ +.container { + position: relative; +} diff --git a/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx new file mode 100644 index 0000000..78387db --- /dev/null +++ b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx @@ -0,0 +1,32 @@ +import React from "react"; +import PT from "prop-types"; +import ToastMessage from "components/ToastrMessage"; +import styles from "./styles.module.scss"; + +/** + * Displays a toastr message with info about the number of resources being + * processed. + * + * @param {Object} props + * @returns {JSX.Element} + */ +const ToastPaymentsProcessing = ({ periods, remove }) => { + return ( + <ToastMessage className={styles.container} remove={remove}> + <span className={styles.icon}></span> + Payment in progress for {periods.length} resources + </ToastMessage> + ); +}; + +ToastPaymentsProcessing.propTypes = { + periods: PT.arrayOf( + PT.shape({ + workPeriodId: PT.string.isRequired, + amount: PT.number.isRequired, + }) + ), + remove: PT.func, +}; + +export default ToastPaymentsProcessing; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsProcessing/styles.module.scss b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/styles.module.scss new file mode 100644 index 0000000..b934fda --- /dev/null +++ b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/styles.module.scss @@ -0,0 +1,16 @@ +.container { + display: flex; + justify-content: center; + align-items: center; +} + +.icon { + display: inline-block; + margin-right: 7px; + width: 24px; + height: 24px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + background-image: url("./../../../../assets/images/icon-dollar-circled.png"); +} diff --git a/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx new file mode 100644 index 0000000..61279aa --- /dev/null +++ b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import PT from "prop-types"; +import ToastMessage from "components/ToastrMessage"; + +/** + * Displays a toastr message with info about the number of resources payments + * for which have been scheduled. + * + * @param {Object} props + * @returns {JSX.Element} + */ +const ToastPaymentsSuccess = ({ periods, remove }) => { + return ( + <ToastMessage type="success" remove={remove}> + Payment scheduled for {periods.length} resources + </ToastMessage> + ); +}; + +ToastPaymentsSuccess.propTypes = { + periods: PT.arrayOf( + PT.shape({ + workPeriodId: PT.string.isRequired, + amount: PT.number.isRequired, + }) + ), + remove: PT.func, +}; + +export default ToastPaymentsSuccess; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsSuccess/styles.module.scss b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/styles.module.scss new file mode 100644 index 0000000..4867abe --- /dev/null +++ b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/styles.module.scss @@ -0,0 +1,3 @@ +.container { + position: relative; +} diff --git a/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx new file mode 100644 index 0000000..0297d34 --- /dev/null +++ b/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx @@ -0,0 +1,58 @@ +import React from "react"; +import PT from "prop-types"; +import ToastMessage from "components/ToastrMessage"; +import styles from "./styles.module.scss"; + +/** + * Displays a toastr message with info about the number of resources for which + * payments have been scheduled or failed to schedule. + * + * @param {Object} props + * @returns {JSX.Element} + */ +const ToastPaymentsWarning = ({ periodsSucceeded, periodsFailed, remove }) => { + return ( + <ToastMessage type="warning" remove={remove}> + Payment scheduled for {periodsSucceeded.length} resources + <br /> + <div className={styles.periodsSucceeded}> + {periodsSucceeded.map((period) => ( + <div key={period.workPeriodId} className={styles.periodSucceeded}> + {period.workPeriodId} + </div> + ))} + </div> + Failed to schedule payment for {periodsFailed.length} resources: + <br /> + <div className={styles.periodsFailed}> + {periodsFailed.map((period) => ( + <div key={period.workPeriodId} className={styles.periodFailed}> + {period.workPeriodId}: ({period.error.code}) {period.error.message} + </div> + ))} + </div> + </ToastMessage> + ); +}; + +ToastPaymentsWarning.propTypes = { + periodsSucceeded: PT.arrayOf( + PT.shape({ + workPeriodId: PT.string.isRequired, + amount: PT.number.isRequired, + }) + ), + periodsFailed: PT.arrayOf( + PT.shape({ + workPeriodId: PT.string.isRequired, + amount: PT.number.isRequired, + error: PT.shape({ + message: PT.string.isRequired, + code: PT.number.isRequired, + }), + }) + ), + remove: PT.func, +}; + +export default ToastPaymentsWarning; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss b/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss new file mode 100644 index 0000000..d0ceca9 --- /dev/null +++ b/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss @@ -0,0 +1,11 @@ +.container { + position: relative; +} + +.periodsSucceeded { + margin-bottom: 10px; +} + +.periodFailed { + background: #ff7b7b; +} diff --git a/src/routes/WorkPeriods/index.jsx b/src/routes/WorkPeriods/index.jsx index 88a4a18..30f15c9 100644 --- a/src/routes/WorkPeriods/index.jsx +++ b/src/routes/WorkPeriods/index.jsx @@ -2,11 +2,9 @@ import React from "react"; import withAuthentication from "hoc/withAuthentication"; import Sidebar from "components/Sidebar"; import Content from "components/Content"; -import ContentHeader from "components/ContentHeader"; import ContentBlock from "components/ContentBlock"; -import Button from "components/Button"; import Page from "components/Page"; -import PageTitle from "components/PageTitle"; +import PeriodsContentHeader from "./components/PeriodsContentHeader"; import PeriodFilters from "./components/PeriodFilters"; import Periods from "./components/Periods"; import PeriodCount from "./components/PeriodCount"; @@ -26,12 +24,7 @@ const WorkPeriods = () => ( <PeriodFilters /> </Sidebar> <Content> - <ContentHeader className={styles.contentHeader}> - <PageTitle text="Working Periods" /> - <Button variant="contained" onClick={() => {}}> - Process Payment - </Button> - </ContentHeader> + <PeriodsContentHeader /> <ContentBlock> <div className={styles.periodsHeader}> <PeriodCount className={styles.periodCount} /> diff --git a/src/routes/WorkPeriods/styles.module.scss b/src/routes/WorkPeriods/styles.module.scss index bedfd27..3a46a12 100644 --- a/src/routes/WorkPeriods/styles.module.scss +++ b/src/routes/WorkPeriods/styles.module.scss @@ -1,12 +1,6 @@ .container { } -.contentHeader { - display: flex; - justify-content: space-between; - align-items: center; -} - .periodsHeader { display: flex; justify-content: space-between; diff --git a/src/services/axios.js b/src/services/axios.js index 5a7cc78..d89223f 100644 --- a/src/services/axios.js +++ b/src/services/axios.js @@ -2,6 +2,8 @@ import axios from "axios"; import get from "lodash/get"; import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; +export const CancelToken = axios.CancelToken; + const axiosInstance = axios.create({ headers: { "Content-Type": "application/json" }, }); diff --git a/src/services/teams.js b/src/services/teams.js new file mode 100644 index 0000000..1ffede2 --- /dev/null +++ b/src/services/teams.js @@ -0,0 +1,13 @@ +import axios from "./axios"; +import config from "../../config"; + +/** + * Get member suggestions + * + * @param {string} fragment text for suggestions + * + * @returns {Promise} + */ +export const getMemberSuggestions = (fragment) => { + return axios.get(`${config.API.V3}/members/_suggest/${fragment}`); +}; diff --git a/src/services/workPeriods.js b/src/services/workPeriods.js index 1bffc8c..9a85245 100644 --- a/src/services/workPeriods.js +++ b/src/services/workPeriods.js @@ -1,9 +1,76 @@ -import axiosDefault from "axios"; -import axios from "./axios"; -import { API_URL, QUERY_PARAM_NAMES } from "constants/workPeriods"; -import { buildRequestQuery } from "utils/misc"; +import axios, { CancelToken } from "./axios"; +import { + RB_API_URL, + JOBS_API_URL, + PAYMENTS_API_URL, + PROJECTS_API_URL, + QUERY_PARAM_NAMES, + WORK_PERIODS_API_URL, +} from "constants/workPeriods"; +import { buildRequestQuery, extractResponseData } from "utils/misc"; -const CancelToken = axiosDefault.CancelToken; +/** + * Fetches job name by job id. + * + * @param {number|string} jobId job id + * @param {Object} [source] axios cancel token source + * @returns {[Promise, Object]} + */ +export const fetchJob = (jobId, source) => { + if (!source) { + source = CancelToken.source(); + } + return [ + axios + .get(`${JOBS_API_URL}/${jobId}`, { + cancelToken: source.token, + }) + .then(extractResponseData), + source, + ]; +}; + +/** + * Fetches billing accounts for specific project id. + * + * @param {number|string} projectId resource booking's project id + * @param {Object} [source] axios cancel token source + * @returns {[Promise, Object]} + */ +export const fetchBillingAccounts = (projectId, source) => { + if (!source) { + source = CancelToken.source(); + } + return [ + axios + .get(`${PROJECTS_API_URL}/${projectId}/billingAccounts`, { + cancelToken: source.token, + }) + .then(extractResponseData), + source, + ]; +}; + +/** + * Fetches working periods for specific resource booking. + * + * @param {string} rbId ResourceBooking id + * @param {Object} [source] optioinal cancel token source + * @returns {[Promise, Object]} + */ +export const fetchWorkPeriods = (rbId, source) => { + if (!source) { + source = CancelToken.source(); + } + return [ + axios + .get(`${WORK_PERIODS_API_URL}/?resourceBookingIds=${rbId}`, { + cancelToken: source.token, + }) + .then(extractResponseData), + source, + ]; +}; /** * Fetches working periods using provided parameters. @@ -14,9 +81,42 @@ const CancelToken = axiosDefault.CancelToken; export const fetchResourceBookings = (params) => { const source = CancelToken.source(); return [ - axios.get(`${API_URL}?${buildRequestQuery(params, QUERY_PARAM_NAMES)}`, { + axios.get(`${RB_API_URL}?${buildRequestQuery(params, QUERY_PARAM_NAMES)}`, { cancelToken: source.token, }), source, ]; }; + +/** + * Updates working period's working days. + * + * @param {string} periodId working period id + * @param {number} daysWorked new number of working days + * @returns {Promise} + */ +export const patchWorkPeriodWorkingDays = (periodId, daysWorked) => { + return axios.patch(`${WORK_PERIODS_API_URL}/${periodId}`, { daysWorked }); +}; + +/** + * Updates billing account id for resource booking with the specified id. + * + * @param {string} rbId resource booking id + * @param {number} billingAccountId billing account id + * @returns {Promise} + */ +export const patchWorkPeriodBillingAccount = (rbId, billingAccountId) => { + return axios.patch(`${RB_API_URL}/${rbId}`, { billingAccountId }); +}; + +/** + * Sends request to queue payments for specific working periods and amounts + * inside the provided array. + * + * @param {Array} payments + * @returns {Promise} + */ +export const postWorkPeriodsPayments = (payments) => { + return axios.post(`${PAYMENTS_API_URL}`, payments).then(extractResponseData); +}; diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 4f38629..064a7ae 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -1,7 +1,23 @@ export const WP_LOAD_PAGE_PENDING = "WP_LOAD_PAGE_PENDING"; export const WP_LOAD_PAGE_SUCCESS = "WP_LOAD_PAGE_SUCCESS"; export const WP_LOAD_PAGE_ERROR = "WP_LOAD_PAGE_ERROR"; +export const WP_HIDE_PERIOD_DETAILS = "WP_HIDE_PERIOD_DETAILS"; +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"; export const WP_RESET_FILTERS = "WP_RESET_FILTERS"; +export const WP_SELECT_PERIODS = "WP_SELECT_PERIODS"; +export const WP_SET_BILLING_ACCOUNT = "WP_SET_BILLING_ACCOUNT"; +export const WP_SET_DETAILS_WORKING_DAYS = "WP_SET_DETAILS_WORKING_DAYS"; +export const WP_SET_DETAILS_HIDE_PAST_PERIODS = + "WP_SET_DETAILS_HIDE_PAST_PERIODS"; +export const WP_SET_DETAILS_LOCK_WORKING_DAYS = + "WP_SET_DETAILS_LOCK_WORKING_DAYS"; 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"; @@ -14,3 +30,4 @@ export const WP_SET_WORKING_DAYS = "WP_SET_WORKING_DAYS"; 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"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index dcfd5e3..a57ad21 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -16,7 +16,7 @@ export const loadWorkPeriodsPagePending = (cancelSource, pageNumber) => ({ }); /** - * Creates an action denoting the saving of fetched challenge page. + * Creates an action denoting the saving of fetched working periods' page. * * @param {Array} periods array of challenge objects * @param {number} totalCount total number of periods for current filters' state @@ -29,14 +29,165 @@ export const loadWorkPeriodsPageSuccess = (periods, totalCount, pageCount) => ({ }); /** - * Creates an action denoting the occurrence of an error while loading challenges. + * Creates an action denoting the occurrence of an error while loading working + * periods. * * @param {string} message error message * @returns {Object} */ export const loadWorkPeriodsPageError = (message) => ({ type: ACTION_TYPE.WP_LOAD_PAGE_ERROR, - payload: { id: nextErrorId++, message }, + payload: { message, id: nextErrorId++ }, +}); + +/** + * Creates an action to hide specific working period details. + * + * @param {string} periodId working period id + * @returns {Object} + */ +export const hideWorkPeriodDetails = (periodId) => ({ + type: ACTION_TYPE.WP_HIDE_PERIOD_DETAILS, + payload: 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} cancelSource axios cancel token source + * @returns {Object} + */ +export const loadWorkPeriodDetailsPending = ( + periodId, + rbId, + billingAccountId, + cancelSource +) => ({ + type: ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_PENDING, + payload: { periodId, rbId, billingAccountId, cancelSource }, +}); + +/** + * Creates an action denoting successful loading of working period details. + * + * @param {string} periodId working period id + * @param {Object} details working period details object + * @returns {Object} + */ +export const loadWorkPeriodDetailsSuccess = (periodId, details) => ({ + type: ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_SUCCESS, + payload: { periodId, details }, +}); + +/** + * Creates an action denoting the occurrence of an error while loading + * working period details. + * + * @param {string} periodId work period id + * @param {string} message error message + * @returns {Object} + */ +export const loadWorkPeriodDetailsError = (periodId, message) => ({ + type: ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_ERROR, + 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 {Array} accounts billing accounts + * @returns {Object} + */ +export const loadBillingAccountsSuccess = (periodId, accounts) => ({ + type: ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_SUCCESS, + payload: { periodId, accounts }, +}); + +/** + * Creates an action denoting an error while loading billing accounts. + * + * @param {string} periodId working period id + * @param {string} message error message + * @returns {Object} + */ +export const loadBillingAccountsError = (periodId, message) => ({ + type: ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_ERROR, + payload: { periodId, message, id: nextErrorId++ }, +}); + +/** + * Creates an action denoting the change of billing account. + * + * @param {string} periodId working period id + * @param {number|string} accountId billing account id + * @returns {Object} + */ +export const setBillingAccount = (periodId, accountId) => ({ + type: ACTION_TYPE.WP_SET_BILLING_ACCOUNT, + payload: { periodId, accountId }, +}); + +/** + * Creates an action denoting the change of working period's working days in + * details view. + * + * @param {string} parentPeriodId parent working period id + * @param {string} periodId working period id + * @param {number} workingDays number of working days + * @returns {Object} + */ +export const setDetailsWorkingDays = ( + parentPeriodId, + periodId, + workingDays +) => ({ + type: ACTION_TYPE.WP_SET_DETAILS_WORKING_DAYS, + payload: { parentPeriodId, periodId, workingDays }, +}); + +/** + * Creates an action denoting the hiding or showing past working periods. + * + * @param {string} periodId working period id + * @param {boolean} hide whether to hide or show past working periods + * @returns {Object} + */ +export const setDetailsHidePastPeriods = (periodId, hide) => ({ + type: ACTION_TYPE.WP_SET_DETAILS_HIDE_PAST_PERIODS, + payload: { periodId, hide }, +}); + +export const setDetailsLockWorkingDays = (periodId, lock) => ({ + type: ACTION_TYPE.WP_SET_DETAILS_LOCK_WORKING_DAYS, + payload: { periodId, lock }, }); /** @@ -48,6 +199,18 @@ export const resetWorkPeriodsFilters = () => ({ type: ACTION_TYPE.WP_RESET_FILTERS, }); +/** + * Creates an action denoting the selection/deselection of specified + * working periods. + * + * @param {Object} periods object with period ids as keys and booleans as values + * @returns {Object} + */ +export const selectWorkPeriods = (periods) => ({ + type: ACTION_TYPE.WP_SELECT_PERIODS, + payload: periods, +}); + /** * Creates an action denoting the changing of working periods' page number. * @@ -181,3 +344,14 @@ export const toggleWorkingPeriodsAll = () => ({ export const toggleWorkingPeriodsVisible = () => ({ type: ACTION_TYPE.WP_TOGGLE_PERIODS_VISIBLE, }); + +/** + * Creates an action denoting the change of processing-payments state. + * + * @param {?boolean} on whether to turn processing-payments state on or off + * @returns {Object} + */ +export const toggleWorkPeriodsProcessingPeyments = (on = null) => ({ + type: ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS, + payload: on, +}); diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 25b1089..891c876 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -5,7 +5,11 @@ import { SORT_ORDER_DEFAULT, PAYMENT_STATUS, } from "constants/workPeriods"; -import { getWeekByDate, updateOptionMap } from "utils/misc"; +import { + filterPeriodsByStartDate, + getWeekByDate, + updateOptionMap, +} from "utils/misc"; const initPagination = () => ({ totalCount: 0, @@ -24,13 +28,38 @@ const initFilters = () => ({ userHandle: "", }); +const cancelSourceDummy = { cancel: () => {} }; + +const initPeriodDetails = ( + periodId, + rbId, + billingAccountId = 0, + cancelSource = cancelSourceDummy +) => ({ + periodId, + rbId, + cancelSource, + jobName: "Loading...", + jobNameIsLoading: true, + billingAccountId, + billingAccounts: [{ value: billingAccountId, label: "Loading..." }], + billingAccountsIsLoading: true, + periods: [], + periodsVisible: [], + periodsIsLoading: true, + hidePastPeriods: false, + lockWorkingDays: false, +}); + const initialState = { error: null, - cancelSource: { cancel: () => {} }, + cancelSource: cancelSourceDummy, periods: [], + periodsDetails: {}, periodsSelected: {}, isSelectedPeriodsAll: false, isSelectedPeriodsVisible: false, + isProcessingPayments: false, pagination: initPagination(), sorting: { criteria: SORT_BY_DEFAULT, @@ -55,6 +84,8 @@ const actionHandlers = { cancelSource, error: null, periods: [], + periodsDetails: {}, + periodsSelected: {}, pagination: pageNumber === state.pagination.pageNumber ? state.pagination @@ -87,6 +118,255 @@ const actionHandlers = { periods: [], }; }, + [ACTION_TYPE.WP_HIDE_PERIOD_DETAILS]: (state, periodId) => { + const periodsDetails = { ...state.periodsDetails }; + delete periodsDetails[periodId]; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_PENDING]: ( + state, + { periodId, rbId, billingAccountId, cancelSource } + ) => { + const periodsDetails = { ...state.periodsDetails }; + periodsDetails[periodId] = initPeriodDetails( + periodId, + rbId, + billingAccountId, + cancelSource + ); + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_SUCCESS]: ( + state, + { periodId, details } + ) => { + 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; + } + periodDetails = { + ...periodDetails, + periods: details.periods, + periodsIsLoading: false, + }; + if (periodDetails.hidePastPeriods) { + periodDetails.periodsVisible = filterPeriodsByStartDate( + periodDetails.periods, + state.filters.dateRange[0] + ); + } else { + periodDetails.periodsVisible = periodDetails.periods; + } + periodsDetails[periodId] = periodDetails; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_ERROR]: ( + state, + { periodId, message } + ) => { + const periodsDetails = { ...state.periodsDetails }; + // No periods to show so we just hide period details. + delete periodsDetails[periodId]; + console.error(message); + return { + ...state, + 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, jobNameIsLoading: false }; + if (!periodDetails.billingAccountsIsLoading) { + periodDetails.cancelSource = null; + } + periodsDetails[periodId] = periodDetails; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_LOAD_JOB_NAME_ERROR]: (state, { periodId, message }) => { + console.error(message); + const periodsDetails = { ...state.periodsDetails }; + let periodDetails = periodsDetails[periodId]; + if (!periodDetails) { + return state; + } + periodDetails = { + ...periodDetails, + jobName: "Error", + jobNameIsLoading: false, + }; + if (!periodDetails.billingAccountsIsLoading) { + periodDetails.cancelSource = null; + } + periodsDetails[periodId] = periodDetails; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_SUCCESS]: ( + state, + { periodId, accounts } + ) => { + 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; + } + let billingAccountId = periodDetails.billingAccountId; + if (!accounts.length) { + accounts.push({ value: -1, label: "No Accounts Available" }); + billingAccountId = -1; + } + periodDetails = { + ...periodDetails, + billingAccountId, + billingAccounts: accounts, + billingAccountsIsLoading: false, + }; + if (!periodDetails.jobNameIsLoading) { + periodDetails.cancelSource = null; + } + periodsDetails[periodId] = periodDetails; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_ERROR]: ( + state, + { periodId, message } + ) => { + console.error(message); + const periodsDetails = { ...state.periodsDetails }; + let periodDetails = periodsDetails[periodId]; + if (!periodDetails) { + return state; + } + periodDetails = { + ...periodDetails, + billingAccounts: [ + { value: periodDetails.billingAccountId, label: "Error" }, + ], + billingAccountsIsLoading: false, + }; + if (!periodDetails.jobNameIsLoading) { + periodDetails.cancelSource = null; + } + periodsDetails[periodId] = periodDetails; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_SET_BILLING_ACCOUNT]: (state, { periodId, accountId }) => { + const periodsDetails = { ...state.periodsDetails }; + const periodDetails = periodsDetails[periodId]; + if (!periodDetails) { + return state; + } + periodsDetails[periodId] = { + ...periodDetails, + billingAccountId: accountId, + }; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_SET_DETAILS_HIDE_PAST_PERIODS]: ( + state, + { periodId, hide } + ) => { + const periodsDetails = { ...state.periodsDetails }; + let periodDetails = periodsDetails[periodId]; + if (!periodDetails) { + return state; + } + periodDetails = { ...periodDetails, hidePastPeriods: hide }; + if (hide) { + periodDetails.periodsVisible = filterPeriodsByStartDate( + periodDetails.periods, + state.filters.dateRange[0] + ); + } else { + periodDetails.periodsVisible = periodDetails.periods; + } + periodsDetails[periodId] = periodDetails; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_SET_DETAILS_LOCK_WORKING_DAYS]: ( + state, + { periodId, lock } + ) => { + const periodsDetails = { ...state.periodsDetails }; + let periodDetails = periodsDetails[periodId]; + if (!periodDetails) { + return state; + } + periodsDetails[periodId] = { ...periodDetails, lockWorkingDays: lock }; + return { + ...state, + periodsDetails, + }; + }, + [ACTION_TYPE.WP_SET_DETAILS_WORKING_DAYS]: ( + state, + { parentPeriodId, periodId, workingDays } + ) => { + const periodsDetails = { ...state.periodsDetails }; + let periodDetails = periodsDetails[parentPeriodId]; + if (!periodDetails) { + return state; + } + workingDays = Math.min(Math.max(workingDays, 0), 5); + const periods = []; + for (let period of periodDetails.periods) { + if (period.id === periodId) { + period = { ...period, workingDays }; + } + periods.push(period); + } + const periodsVisible = []; + for (let period of periodDetails.periodsVisible) { + if (period.id === periodId) { + period = { ...period, workingDays }; + } + periodsVisible.push(period); + } + periodsDetails[parentPeriodId] = { + ...periodDetails, + periods, + periodsVisible, + }; + return { + ...state, + periodsDetails, + }; + }, [ACTION_TYPE.WP_RESET_FILTERS]: (state) => ({ ...state, filters: initFilters(), @@ -105,6 +385,20 @@ const actionHandlers = { }, }; }, + [ACTION_TYPE.WP_SELECT_PERIODS]: (state, periods) => { + let periodsSelected = { ...state.periodsSelected }; + for (let periodId in periods) { + if (periods[periodId] === true) { + periodsSelected[periodId] = true; + } else { + delete periodsSelected[periodId]; + } + } + return { + ...state, + periodsSelected, + }; + }, [ACTION_TYPE.WP_SET_PAGE_NUMBER]: (state, pageNumber) => ({ ...state, pagination: @@ -226,6 +520,10 @@ const actionHandlers = { isSelectedPeriodsVisible: isSelected, }; }, + [ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS]: (state, on) => ({ + ...state, + isProcessingPayments: on === null ? !state.isProcessingPayments : on, + }), }; export default reducer; diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js index 9ffcfa0..3719420 100644 --- a/src/store/selectors/workPeriods.js +++ b/src/store/selectors/workPeriods.js @@ -14,6 +14,15 @@ export const getWorkPeriodsStateSlice = (state) => state.workPeriods; */ export const getWorkPeriods = (state) => state.workPeriods.periods; +/** + * Returns working periods' details. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getWorkPeriodsDetails = (state) => + state.workPeriods.periodsDetails; + /** * Returns an object with working periods' ids as keys and booleans showing * whether the period is selected as values. @@ -46,9 +55,20 @@ export const getWorkPeriodsCount = (state) => state.workPeriods.periods.length; 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 getWorkPeriodsIsLoading = (state) => !!state.workPeriods.cancelSource; +export const getWorkPeriodsIsProcessingPayments = (state) => + state.workPeriods.isProcessingPayments; + export const getWorkPeriodsIsSelectedAll = (state) => state.workPeriods.isSelectedPeriodsAll; diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js deleted file mode 100644 index 29c0c38..0000000 --- a/src/store/thunks/workPeriods.js +++ /dev/null @@ -1,90 +0,0 @@ -import axios from "axios"; -import * as actions from "store/actions/workPeriods"; -import * as selectors from "store/selectors/workPeriods"; -import * as services from "services/workPeriods"; -import { - SORT_BY_MAP, - API_SORT_BY, - DATE_FORMAT, - PAYMENT_STATUS_MAP, - FIELDS_QUERY, -} from "constants/workPeriods"; -import { - extractResponseData, - extractResponsePagination, - replaceItems, -} from "utils/misc"; -import { normalizePeriodItems } from "utils/workPeriods"; -import { RESOURCE_BOOKING_STATUS } from "constants/index.js"; - -/** - * Thunk that loads the specified working periods' page. If page number is not - * provided the current page number from current state is used. All relevant - * working period filters are loaded from the current state to construct - * a request query. - * - * @param {number} [pageNumber] page number to load - * @returns {function} - */ -export const loadWorkPeriodsPage = - (pageNumber) => async (dispatch, getState) => { - const workPeriods = selectors.getWorkPeriodsStateSlice(getState()); - if (workPeriods.cancelSource) { - // If there's an ongoing request we just cancel it since the data that comes - // with its response will not correspond to application's current state, - // namely filters and sorting. - workPeriods.cancelSource.cancel(); - } - const { filters, sorting, pagination } = workPeriods; - - // If page number is not specified get it from current state. - pageNumber = pageNumber || pagination.pageNumber; - - const sortOrder = sorting.order; - const sortBy = SORT_BY_MAP[sorting.criteria] || API_SORT_BY.USER_HANDLE; - - const [startDate] = filters.dateRange; - const paymentStatuses = replaceItems( - Object.keys(filters.paymentStatuses), - PAYMENT_STATUS_MAP - ); - - // For parameter description see: - // https://topcoder-platform.github.io/taas-apis/#/ResourceBookings/get_resourceBookings - const [promise, cancelSource] = services.fetchResourceBookings({ - fields: FIELDS_QUERY, - page: pageNumber, - perPage: pagination.pageSize, - sortBy, - sortOrder, - // we only want to show Resource Bookings with status "placed" - status: RESOURCE_BOOKING_STATUS.PLACED, - ["workPeriods.userHandle"]: filters.userHandle, - ["workPeriods.startDate"]: startDate.format(DATE_FORMAT), - ["workPeriods.paymentStatus"]: - // Currently resourceBookings API does not support multiple payment statuses. - // When the support is implemented remove the next line and uncomment - // the following line. - paymentStatuses.length === 1 ? paymentStatuses[0] : null, - // paymentStatuses, - }); - dispatch(actions.loadWorkPeriodsPagePending(cancelSource, pageNumber)); - let totalCount, periods, pageCount; - try { - const response = await promise; - ({ totalCount, pageNumber, pageCount } = - extractResponsePagination(response)); - const data = extractResponseData(response); - periods = normalizePeriodItems(data); - } catch (error) { - // If request was cancelled by the next call to loadWorkPeriodsPage - // there's nothing more to do. - if (!axios.isCancel(error)) { - dispatch(actions.loadWorkPeriodsPageError(error.toString())); - } - return; - } - dispatch( - actions.loadWorkPeriodsPageSuccess(periods, totalCount, pageCount) - ); - }; diff --git a/src/store/thunks/workPeriods.jsx b/src/store/thunks/workPeriods.jsx new file mode 100644 index 0000000..2db85ff --- /dev/null +++ b/src/store/thunks/workPeriods.jsx @@ -0,0 +1,336 @@ +import React from "react"; +import axios from "axios"; +import { toastr } from "react-redux-toastr"; +import ToastrMessage from "components/ToastrMessage"; +import ToastPaymentsProcessing from "routes/WorkPeriods/components/ToastPaymentsProcessing"; +import ToastPaymentsSuccess from "routes/WorkPeriods/components/ToastPaymentsSuccess"; +import ToastPaymentsWarning from "routes/WorkPeriods/components/ToastPaymentsWarning"; +import ToastPaymentsError from "routes/WorkPeriods/components/ToastPaymentsError"; +import * as actions from "store/actions/workPeriods"; +import * as selectors from "store/selectors/workPeriods"; +import * as services from "services/workPeriods"; +import { + SORT_BY_MAP, + API_SORT_BY, + DATE_FORMAT_API, + PAYMENT_STATUS_MAP, + FIELDS_QUERY, +} from "constants/workPeriods"; +import { + extractJobName, + extractResponseData, + extractResponsePagination, + replaceItems, +} from "utils/misc"; +import { + normalizeBillingAccounts, + normalizeDetailsPeriodItems, + normalizePeriodItems, +} from "utils/workPeriods"; +import { RESOURCE_BOOKING_STATUS } from "constants/index.js"; + +/** + * Thunk that loads the specified working periods' page. If page number is not + * provided the current page number from current state is used. All relevant + * working period filters are loaded from the current state to construct + * a request query. + * + * @param {number} [pageNumber] page number to load + * @returns {function} + */ +export const loadWorkPeriodsPage = + (pageNumber) => async (dispatch, getState) => { + const workPeriods = selectors.getWorkPeriodsStateSlice(getState()); + if (workPeriods.cancelSource) { + // If there's an ongoing request we just cancel it since the data that comes + // with its response will not correspond to application's current state, + // namely filters and sorting. + workPeriods.cancelSource.cancel(); + } + const { filters, sorting, pagination } = workPeriods; + + // If page number is not specified get it from current state. + pageNumber = pageNumber || pagination.pageNumber; + + const sortOrder = sorting.order; + const sortBy = SORT_BY_MAP[sorting.criteria] || API_SORT_BY.USER_HANDLE; + + const [startDate] = filters.dateRange; + const paymentStatuses = replaceItems( + Object.keys(filters.paymentStatuses), + PAYMENT_STATUS_MAP + ); + + // For parameter description see: + // https://topcoder-platform.github.io/taas-apis/#/ResourceBookings/get_resourceBookings + const [promise, cancelSource] = services.fetchResourceBookings({ + fields: FIELDS_QUERY, + page: pageNumber, + perPage: pagination.pageSize, + sortBy, + sortOrder, + // we only want to show Resource Bookings with status "placed" + status: RESOURCE_BOOKING_STATUS.PLACED, + ["workPeriods.userHandle"]: filters.userHandle, + ["workPeriods.startDate"]: startDate.format(DATE_FORMAT_API), + ["workPeriods.paymentStatus"]: + // Currently resourceBookings API does not support multiple payment statuses. + // When the support is implemented remove the next line and uncomment + // the following line. + paymentStatuses.length === 1 ? paymentStatuses[0] : null, + // paymentStatuses, + }); + dispatch(actions.loadWorkPeriodsPagePending(cancelSource, pageNumber)); + let totalCount, periods, pageCount; + try { + const response = await promise; + ({ totalCount, pageNumber, pageCount } = + extractResponsePagination(response)); + const data = extractResponseData(response); + periods = normalizePeriodItems(data); + } catch (error) { + // If request was cancelled by the next call to loadWorkPeriodsPage + // there's nothing more to do. + if (!axios.isCancel(error)) { + dispatch(actions.loadWorkPeriodsPageError(error.toString())); + } + return; + } + dispatch( + actions.loadWorkPeriodsPageSuccess(periods, totalCount, pageCount) + ); + }; + +/** + * Thunk that either loads and displays or hides the details of the specified + * working period. + * + * @param {Object} period working period object + * @param {string} period.id working period id + * @param {string} period.rbId resource booking id + * @param {number|string} period.projectId resource booking's project id + * @param {number|string} period.jobId resource booking's job id + * @param {number} period.billingAccountId billing account id + * @param {?boolean} [show] whether to show or hide working period details + * @returns {function} + */ +export const toggleWorkPeriodDetails = + (period, show = null) => + async (dispatch, getState) => { + const periodsDetails = selectors.getWorkPeriodsDetails(getState()); + const periodDetails = periodsDetails[period.id]; + // If there's an ongoing request to load details for specified working + // period we cancel this request because + // 1. If we show details the data that will come with its response will not + // correspond to the current state. + // 2. If we hide details we don't need details data anyway. + periodDetails?.cancelSource?.cancel(); + show = show === null ? !periodDetails : show; + if (show) { + if (periodDetails) { + // reload details? + } else { + const source = axios.CancelToken.source(); + dispatch( + actions.loadWorkPeriodDetailsPending( + period.id, + period.rbId, + period.billingAccountId, + source + ) + ); + const [rbPromise] = services.fetchWorkPeriods(period.rbId, source); + const [jobNamePromise] = services.fetchJob(period.jobId, source); + const [bilAccsPromise] = services.fetchBillingAccounts( + period.projectId, + source + ); + let details = null; + let errorMessage = null; + try { + const data = await rbPromise; + const periods = normalizeDetailsPeriodItems(data); + details = { periods }; + } catch (error) { + if (!axios.isCancel(error)) { + errorMessage = error.toString(); + } + } + if (details) { + dispatch(actions.loadWorkPeriodDetailsSuccess(period.id, details)); + } else if (errorMessage) { + dispatch(actions.loadWorkPeriodDetailsError(period.id, errorMessage)); + makeToast(errorMessage); + } + let jobName = null; + errorMessage = null; + try { + const data = await jobNamePromise; + jobName = extractJobName(data); + } catch (error) { + if (!axios.isCancel(error)) { + errorMessage = error.toString(); + } + } + if (jobName) { + dispatch(actions.loadJobNameSuccess(period.id, jobName)); + } else if (errorMessage) { + dispatch(actions.loadJobNameError(period.id, errorMessage)); + makeToast(errorMessage); + } + let accounts = null; + errorMessage = null; + try { + const data = await bilAccsPromise; + const periodsDetails = selectors.getWorkPeriodsDetails(getState()); + const periodDetails = periodsDetails[period.id]; + const billingAccountId = + (periodDetails && periodDetails.billingAccountId) || + period.billingAccountId; + accounts = normalizeBillingAccounts(data, billingAccountId); + } catch (error) { + if (!axios.isCancel(error)) { + errorMessage = error.toString(); + } + } + if (accounts) { + dispatch(actions.loadBillingAccountsSuccess(period.id, accounts)); + } else if (errorMessage) { + dispatch(actions.loadBillingAccountsError(period.id, errorMessage)); + makeToast(errorMessage); + } + } + } else { + dispatch(actions.hideWorkPeriodDetails(period.id)); + } + }; + +/** + * + * @param {string} rbId + * @param {number} billingAccountId + * @returns {function} + */ +export const updateWorkPeriodBillingAccount = + (rbId, billingAccountId) => async () => { + try { + await services.patchWorkPeriodBillingAccount(rbId, billingAccountId); + } catch (error) { + makeToast( + `Failed to update billing account for resource booking ${rbId}.\n` + + error.toString() + ); + } + }; + +/** + * + * @param {string} periodId + * @param {number} workingDays + * @returns {function} + */ +export const updateWorkPeriodWorkingDays = + (periodId, workingDays) => async () => { + try { + await services.patchWorkPeriodWorkingDays(periodId, workingDays); + } catch (error) { + makeToast( + `Failed to update working days for working period ${periodId}.\n` + + error.toString() + ); + } + }; + +/** + * Sends request to process payments for selected working periods. + * + * @param {function} dispatch redux store dispatch function + * @param {function} getState function returning redux store root state + */ +export const processPayments = async (dispatch, getState) => { + dispatch(actions.toggleWorkPeriodsProcessingPeyments(true)); + const state = getState(); + const periods = selectors.getWorkPeriods(state); + const periodsSelected = selectors.getWorkPeriodsSelected(state); + const payments = []; + for (let period of periods) { + if (period.id in periodsSelected) { + payments.push({ workPeriodId: period.id, amount: period.weeklyRate }); + } + } + makeProcessingToast(payments); + let results = null; + let errorMessage = null; + try { + results = await services.postWorkPeriodsPayments(payments); + } catch (error) { + errorMessage = error.toString(); + } + if (results) { + const periodsToDeselect = {}; + const periodsSucceeded = []; + const periodsFailed = []; + for (let result of results) { + if ("error" in result) { + periodsFailed.push(result); + } else { + periodsToDeselect[result.workPeriodId] = false; + periodsSucceeded.push(result); + } + } + dispatch(actions.selectWorkPeriods(periodsToDeselect)); + if (periodsSucceeded.length) { + if (periodsFailed.length) { + makeWarningToast(periodsSucceeded, periodsFailed); + } else { + makeSuccessToast(periodsSucceeded); + } + } else { + makeErrorToast(periodsFailed); + } + } else { + makeToast(errorMessage); + } + dispatch(actions.toggleWorkPeriodsProcessingPeyments(false)); +}; + +/** + * + * @param {string} message + * @param {'info'|'success'|'warning'|'error'} type + * @returns {Object} + */ +function makeToast(message, type = "error") { + const component = + typeof message === "string" ? ( + <ToastrMessage message={message} type={type} /> + ) : ( + <ToastrMessage type={type}>{message}</ToastrMessage> + ); + toastr[type]("", { component }); +} + +function makeProcessingToast(periods) { + const component = <ToastPaymentsProcessing periods={periods} />; + toastr.info("", { component }); +} + +function makeSuccessToast(periods) { + const component = <ToastPaymentsSuccess periods={periods} />; + toastr.success("", { component }); +} + +function makeWarningToast(periodsSucceeded, periodsFailed) { + const component = ( + <ToastPaymentsWarning + periodsSucceeded={periodsSucceeded} + periodsFailed={periodsFailed} + /> + ); + toastr.warning("", { component }); +} + +function makeErrorToast(periods) { + const component = <ToastPaymentsError periods={periods} />; + toastr.error("", { component }); +} diff --git a/src/styles/toastr.scss b/src/styles/toastr.scss index 8367c56..0dc3193 100644 --- a/src/styles/toastr.scss +++ b/src/styles/toastr.scss @@ -1,3 +1,49 @@ +@import "mixins"; +@import "variables"; + .redux-toastr { position: absolute; + left: $sidebar-width; + top: 0; + right: 0; + margin: 0; + border: none; + padding: 0; + height: auto; + background: transparent; + + > div { + position: absolute; + left: 22px; + top: 24px; + right: 14px; + margin: 0; + border: none; + padding: 0; + height: auto; + background: transparent; + } + + .top-right { + z-index: 1000; + position: absolute; + left: 0; + top: 0; + right: 0; + + > div { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } + } + + .toastr { + .rrt-left-container, + .rrt-right-container { + display: none; + } + } } diff --git a/src/styles/variables/_colors.scss b/src/styles/variables/_colors.scss index 8a9b503..9e8c6ba 100644 --- a/src/styles/variables/_colors.scss +++ b/src/styles/variables/_colors.scss @@ -1,11 +1,25 @@ $primary-color: #137d60; $primary-text-color: #229174; $primary-light-color: #0ab88a; -$primary-light-text-color: #0ab88a; // currently not used, cen be changed -$primary-dark-color: #137d60; // currently not used, cen be changed -$primary-dark-text-color: #137d60; // currently not used, cen be changed +$primary-light-text-color: #0ab88a; // currently not used, can be changed +$primary-dark-color: #137d60; // currently not used, can be changed +$primary-dark-text-color: #137d60; // currently not used, can be changed $text-color: #2a2a2a; $page-bg-color: #f4f5f6; $control-border-color: #aaa; +$control-disabled-border-color: lighten( + $color: $control-border-color, + $amount: 5%, +); +$control-disabled-bg-color: lighten( + $color: $control-border-color, + $amount: 10%, +); +$control-disabled-text-color: lighten( + $color: $text-color, + $amount: 10%, +); + $checkbox-bg-color: $primary-light-color; +$toggle-active-bg-color: $primary-light-color; diff --git a/src/utils/formatters.js b/src/utils/formatters.js index bd67d1f..d720727 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -1,9 +1,57 @@ +import moment from "moment"; import isNumber from "lodash/isNumber"; import { PAYMENT_STATUS_LABELS } from "constants/workPeriods"; -import { PLATFORM_WEBSITE_URL, TAAS_BASE_PATH } from "../constants"; +import { + PLATFORM_WEBSITE_URL, + TAAS_BASE_PATH, + TOPCODER_WEBSITE_URL, +} from "../constants"; const rxWhitespace = /\s+/; +/** + * Creates a challenge URL using challenge id. + * + * @param {number} challengeId challenge id + * @returns {string} + */ +export function formatChallengeUrl(challengeId) { + return `${TOPCODER_WEBSITE_URL}/challenges/${challengeId}`; +} + +/** + * Returns a string denoting whether the specified start date corresponds to the + * current period or future period. + * + * @param {*} startDate start date + * @param {*} currentStartDate start date of currently selected period + * @returns {string} + */ +export function formatDateLabel(startDate, currentStartDate) { + let start = moment(startDate); + let currentStart = moment(currentStartDate); + if (start.isSame(currentStart, "date")) { + return "Current Period"; + } + if (start.isAfter(currentStart, "date")) { + return "Future Period"; + } + return ""; +} + +/** + * Formats working period's date range. + * + * @param {number|string} startDate working period start date + * @param {number|string} endDate working period end date + * @returns {string} + */ +export function formatDateRange(startDate, endDate) { + let start = moment(startDate); + let end = moment(endDate); + return `${start.format("DD MMM, YYYY")} to ${end.format("DD MMM, YYYY")}`; +} + /** * Formats payment status. * diff --git a/src/utils/hooks.js b/src/utils/hooks.js index 82e138f..717943d 100644 --- a/src/utils/hooks.js +++ b/src/utils/hooks.js @@ -1,5 +1,32 @@ import { useEffect, useRef } from "react"; +/** + * By "click" it is implied "mousedown" or "touchstart" + * + * @param {Object} ref element reference obtained with useRef + * @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) => { + useEffect(() => { + const onClick = (event) => { + let elem = ref.current; + if (elem && !elem.contains(event.target)) { + listener(); + } + }; + document.addEventListener("mousedown", onClick); + document.addEventListener("touchstart", onClick); + return () => { + document.removeEventListener("touchstart", onClick); + document.removeEventListener("mousedown", onClick); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +}; + /** * A hook that calls effect only if dependencies or effect itself change. * diff --git a/src/utils/misc.js b/src/utils/misc.js index f931c62..97d8215 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -1,5 +1,27 @@ import moment from "moment"; +/** + * Returns working periods filtered by start date. + * + * @param {Array} periods array of working period items that contain startDate + * @param {string|Object} startDate value denoting start date + * that can be accepted by momentjs + * @returns {Array} + */ +export function filterPeriodsByStartDate(periods, startDate) { + if (!startDate) { + return periods; + } + const items = []; + startDate = moment(startDate); + for (let period of periods) { + if (moment(period.startDate).isSameOrAfter(startDate, "date")) { + items.push(period); + } + } + return items; +} + /** * Returns the option which matches the provided value or null. * @@ -109,4 +131,10 @@ export const extractResponsePagination = ({ headers }) => ({ pageSize: +headers["x-per-page"] || 10, }); +export const extractJobName = (data) => data.title; + export const extractResponseData = (response) => response.data; + +export function stopPropagation(event) { + event.stopPropagation(); +} diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 2258b73..253d7fb 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -1,19 +1,23 @@ import moment from "moment"; -import { API_PAYMENT_STATUS_MAP, PAYMENT_STATUS } from "constants/workPeriods"; - -const DATE_FORMAT_UI = "MMM DD, YYYY"; +import { + API_PAYMENT_STATUS_MAP, + DATE_FORMAT_UI, + PAYMENT_STATUS, +} from "constants/workPeriods"; export function normalizePeriodItems(items) { const empty = {}; const periods = []; for (let item of items) { const workPeriod = item.workPeriods?.[0] || empty; - const paymentStatus = workPeriod.paymentStatus; + const billingAccountId = item.billingAccountId; const daysWorked = workPeriod.daysWorked; periods.push({ id: workPeriod.id || item.id, rbId: item.id, + jobId: item.jobId, projectId: item.projectId, + billingAccountId: billingAccountId === null ? 0 : billingAccountId, teamName: "", userHandle: workPeriod.userHandle || "", startDate: item.startDate @@ -21,11 +25,66 @@ export function normalizePeriodItems(items) { : "", endDate: item.endDate ? moment(item.endDate).format(DATE_FORMAT_UI) : "", weeklyRate: item.memberRate, - paymentStatus: paymentStatus - ? API_PAYMENT_STATUS_MAP[paymentStatus] || paymentStatus.toUpperCase() - : PAYMENT_STATUS.UNDEFINED, + paymentStatus: normalizePaymentStatus(workPeriod.paymentStatus), workingDays: daysWorked === null ? 5 : +daysWorked || 0, }); } return periods; } + +/** + * Creates options to be used in dropdown selecting working period's + * 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) { + 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") + : ""; + accs.push({ + value, + label: `${acc.name} (${value})` + (endDate ? ` - ${endDate}` : ""), + }); + } + if (!hasSelectedAccount && accountId > 0) { + accs.unshift({ + value: accountId, + label: `<Assigned Account> (${accountId})`, + }); + } + return accs; +} + +export function normalizeDetailsPeriodItems(items) { + const periods = []; + for (let item of items) { + const daysWorked = item.daysWorked; + periods.push({ + id: item.id, + startDate: item.startDate ? moment(item.startDate).valueOf() : 0, + endDate: item.endDate ? moment(item.endDate).valueOf() : 0, + paymentStatus: normalizePaymentStatus(item.paymentStatus), + payments: item.payments || [], + weeklyRate: item.memberRate, + workingDays: daysWorked === null ? 5 : +daysWorked || 0, + }); + } + periods.sort(sortByStartDate); + return periods; +} + +export function normalizePaymentStatus(paymentStatus) { + return API_PAYMENT_STATUS_MAP[paymentStatus] || PAYMENT_STATUS.UNDEFINED; +} + +export function sortByStartDate(itemA, itemB) { + return itemA.startDate - itemB.startDate; +}