diff --git a/src/components/Page/index.jsx b/src/components/Page/index.jsx index 2a85959..a9b51b0 100644 --- a/src/components/Page/index.jsx +++ b/src/components/Page/index.jsx @@ -2,6 +2,7 @@ import React from "react"; import PT from "prop-types"; import cn from "classnames"; import ReduxToastr from "react-redux-toastr"; +import { TOAST_DEFAULT_TIMEOUT } from "constants/index.js"; import styles from "./styles.module.scss"; /** @@ -16,8 +17,12 @@ const Page = ({ className, children }) => ( <div className={cn(styles.container, className)}> {children} <ReduxToastr - timeOut={60000} + timeOut={TOAST_DEFAULT_TIMEOUT} position="top-right" + newestOnTop={true} + removeOnHover={false} + removeOnHoverTimeOut={TOAST_DEFAULT_TIMEOUT} + closeOnToastrClick={false} transitionIn="fadeIn" transitionOut="fadeOut" /> diff --git a/src/components/ProjectName/index.jsx b/src/components/ProjectName/index.jsx new file mode 100644 index 0000000..463c531 --- /dev/null +++ b/src/components/ProjectName/index.jsx @@ -0,0 +1,25 @@ +import React, { useContext, useEffect } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import { ProjectNameContext } from "components/ProjectNameContextProvider"; +import styles from "./styles.module.scss"; + +const ProjectName = ({ className, projectId }) => { + const [getName, fetchName] = useContext(ProjectNameContext); + useEffect(() => { + fetchName(projectId); + }, [fetchName, projectId]); + + return ( + <span className={cn(styles.container, className)}> + {getName(projectId) || projectId} + </span> + ); +}; + +ProjectName.propTypes = { + className: PT.string, + projectId: PT.number.isRequired, +}; + +export default ProjectName; diff --git a/src/components/ProjectName/styles.module.scss b/src/components/ProjectName/styles.module.scss new file mode 100644 index 0000000..f098c8d --- /dev/null +++ b/src/components/ProjectName/styles.module.scss @@ -0,0 +1,5 @@ +@import "styles/mixins"; + +.container { + @include roboto-medium; +} diff --git a/src/components/ProjectNameContextProvider/index.jsx b/src/components/ProjectNameContextProvider/index.jsx new file mode 100644 index 0000000..f280662 --- /dev/null +++ b/src/components/ProjectNameContextProvider/index.jsx @@ -0,0 +1,47 @@ +import React, { createContext, useCallback, useState } from "react"; +import PT from "prop-types"; +import { fetchProject } from "services/workPeriods"; +import { increment, noop } from "utils/misc"; + +const names = {}; +const promises = {}; + +const getName = (id) => names[id]; + +export const ProjectNameContext = createContext([ + getName, + (id) => { + `${id}`; + }, +]); + +const ProjectNameProvider = ({ children }) => { + const [, setCount] = useState(Number.MIN_SAFE_INTEGER); + + const fetchName = useCallback((id) => { + if (id in names || id in promises) { + return; + } + promises[id] = fetchProject(id) + .then((data) => { + names[id] = data.name; + setCount(increment); + }) + .catch(noop) + .finally(() => { + delete promises[id]; + }); + }, []); + + return ( + <ProjectNameContext.Provider value={[getName, fetchName]}> + {children} + </ProjectNameContext.Provider> + ); +}; + +ProjectNameProvider.propTypes = { + children: PT.node, +}; + +export default ProjectNameProvider; diff --git a/src/components/SearchHandleField/index.jsx b/src/components/SearchHandleField/index.jsx index 38a2168..804b919 100644 --- a/src/components/SearchHandleField/index.jsx +++ b/src/components/SearchHandleField/index.jsx @@ -1,10 +1,8 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback } 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 = { @@ -12,6 +10,10 @@ const selectComponents = { IndicatorSeparator: () => null, }; +const loadingMessage = () => "Loading..."; + +const noOptionsMessage = () => "No suggestions"; + /** * Displays search input field. * @@ -25,20 +27,31 @@ const selectComponents = { * @param {string} props.value input value * @returns {JSX.Element} */ -const SearchAutocomplete = ({ +const SearchHandleField = ({ className, id, + name, size = "medium", onChange, placeholder, value, }) => { - // const option = getOptionByValue(options, value); - const [savedInput, setSavedInput] = useState(""); - const onValueChange = useCallback( - (option) => { - onChange(option.value); + (option, { action }) => { + if (action === "clear") { + onChange(""); + } else { + onChange(option.value); + } + }, + [onChange] + ); + + const onInputChange = useCallback( + (value, { action }) => { + if (action === "input-change") { + onChange(value); + } }, [onChange] ); @@ -51,52 +64,52 @@ const SearchAutocomplete = ({ classNamePrefix="custom" components={selectComponents} id={id} + name={name} + isClearable={true} isSearchable={true} // menuIsOpen={true} // for debugging - // onChange={onOptionChange} - // onMenuOpen={onMenuOpen} - // onMenuClose={onMenuClose} - value={{ value, label: value }} - onInputChange={setSavedInput} - onFocus={() => { - setSavedInput(""); - onChange(savedInput); - }} - placeholder={placeholder} + value={null} + inputValue={value} onChange={onValueChange} - noOptionsMessage={() => "No options"} - loadingMessage={() => "Loading..."} + onInputChange={onInputChange} + openMenuOnClick={false} + placeholder={placeholder} + noOptionsMessage={noOptionsMessage} + loadingMessage={loadingMessage} loadOptions={loadSuggestions} - blurInputOnSelect + cacheOptions /> </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 []; - }); +const loadSuggestions = async (inputVal) => { + let options = []; + if (inputVal.length < 3) { + return options; + } + try { + const res = await getMemberSuggestions(inputVal); + const users = res.data.result.content; + for (let i = 0, len = users.length; i < len; i++) { + let value = users[i].handle; + options.push({ value, label: value }); + } + } catch (error) { + console.error(error); + console.warn("could not get suggestions"); + } + return options; }; -SearchAutocomplete.propTypes = { +SearchHandleField.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; +export default SearchHandleField; diff --git a/src/components/SearchHandleField/styles.module.scss b/src/components/SearchHandleField/styles.module.scss index e6614cc..266d7d4 100644 --- a/src/components/SearchHandleField/styles.module.scss +++ b/src/components/SearchHandleField/styles.module.scss @@ -2,6 +2,7 @@ @import "styles/mixins"; .container { + position: relative; display: flex; align-items: center; border: 1px solid $control-border-color; @@ -18,6 +19,11 @@ } .icon { + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; margin: auto 10px; width: 16px; height: 16px; @@ -42,7 +48,7 @@ input.input { display: flex; margin: 0; border: none !important; - padding: 8px 16px 8px 0; + padding: 8px 16px 8px 36px; line-height: 22px; background: none; outline: none !important; @@ -68,6 +74,7 @@ input.input { margin: 0; border: none; padding: 0; + max-width: none !important; > * { display: flex; @@ -79,6 +86,8 @@ input.input { margin: 0; border: none; padding: 0; + max-width: none !important; + transform: none !important; } input { @@ -86,12 +95,14 @@ input.input { margin: 0 !important; padding: 0 !important; border: none !important; + max-width: none !important; width: auto !important; height: 22px !important; outline: none !important; box-shadow: none !important; line-height: 22px; color: inherit; + opacity: 1 !important; } } @@ -103,7 +114,8 @@ input.input { :global(.custom__input) { flex: 1 1 0; - display: flex; + display: flex !important; + max-width: none !important; } :global(.custom__placeholder) { @@ -115,9 +127,12 @@ input.input { } :global(.custom__menu) { - margin: 1px 0 0; + left: -1px; + right: -1px; + margin: 2px 0 0; border: 1px solid $control-border-color; border-radius: 0; + width: auto; box-shadow: none; } @@ -141,8 +156,8 @@ input.input { } } - :global(.custom__option--is-selected) { - background-color: #229174 !important; + :global(.custom__option--is-focused) { + background-color: $primary-text-color !important; color: #fff; } } diff --git a/src/components/ToastrMessage/index.jsx b/src/components/ToastrMessage/index.jsx index 32aa210..bd51c74 100644 --- a/src/components/ToastrMessage/index.jsx +++ b/src/components/ToastrMessage/index.jsx @@ -1,6 +1,8 @@ import React from "react"; +import { toastr } from "react-redux-toastr"; import PT from "prop-types"; import cn from "classnames"; +import { TOAST_DEFAULT_TIMEOUT } from "constants/index.js"; import styles from "./styles.module.scss"; /** @@ -38,3 +40,19 @@ ToastrMessage.propTypes = { }; export default ToastrMessage; + +/** + * Creates a redux toastr message with the specified type and contents. + * + * @param {string|Object} message + * @param {'info'|'success'|'warning'|'error'} type + */ +export function makeToast(message, type = "error") { + const component = + typeof message === "string" ? ( + <ToastrMessage message={message} type={type} /> + ) : ( + <ToastrMessage type={type}>{message}</ToastrMessage> + ); + toastr[type]("", { component, options: { timeOut: TOAST_DEFAULT_TIMEOUT } }); +} diff --git a/src/components/ToastrMessage/styles.module.scss b/src/components/ToastrMessage/styles.module.scss index 606e378..b06041b 100644 --- a/src/components/ToastrMessage/styles.module.scss +++ b/src/components/ToastrMessage/styles.module.scss @@ -1,13 +1,19 @@ +@import "styles/mixins"; + .container { display: block; position: relative; border-radius: 8px; - padding: 14px 27px 15px 64px; + padding: 14px 42px 15px; font-size: 16px; line-height: 26px; text-align: center; color: #fff; + @include desktop { + padding: 14px 64px 15px; + } + a { color: #fff; } @@ -20,7 +26,7 @@ .btnClose { position: absolute; top: 22px; - right: 27px; + right: 22px; margin: 0; border: none; padding: 0; @@ -31,6 +37,10 @@ outline: none !important; cursor: pointer; + @include desktop { + right: 27px; + } + &::before, &::after { content: ""; diff --git a/src/constants/index.js b/src/constants/index.js index 51f91e6..198dbbb 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -16,3 +16,5 @@ export const RESOURCE_BOOKING_STATUS = { CLOSED: "closed", CANCELLED: "cancelled", }; + +export const TOAST_DEFAULT_TIMEOUT = 50000; diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 5de195d..3caa7e6 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -63,6 +63,7 @@ export const SORT_BY_MAP = { export const PAYMENT_STATUS_LABELS = { [PAYMENT_STATUS.CANCELLED]: "Cancelled", + [PAYMENT_STATUS.FAILED]: "Failed", [PAYMENT_STATUS.PAID]: "Paid", [PAYMENT_STATUS.PENDING]: "Pending", [PAYMENT_STATUS.IN_PROGRESS]: "In Progress", @@ -71,6 +72,7 @@ export const PAYMENT_STATUS_LABELS = { export const PAYMENT_STATUS_MAP = { [PAYMENT_STATUS.CANCELLED]: API_PAYMENT_STATUS.CANCELLED, + [PAYMENT_STATUS.FAILED]: API_PAYMENT_STATUS.FAILED, [PAYMENT_STATUS.PAID]: API_PAYMENT_STATUS.COMPLETED, [PAYMENT_STATUS.PENDING]: API_PAYMENT_STATUS.PENDING, [PAYMENT_STATUS.IN_PROGRESS]: API_PAYMENT_STATUS.PARTIALLY_COMPLETED, @@ -85,3 +87,11 @@ export const API_PAYMENT_STATUS_MAP = (function () { } return obj; })(); + +export const JOB_NAME_LOADING = "Loading..."; +export const JOB_NAME_NONE = "<Job is not assigned>"; +export const JOB_NAME_ERROR = "<Error loading job>"; + +export const BILLING_ACCOUNTS_LOADING = "Loading..."; +export const BILLING_ACCOUNTS_NONE = "<No accounts available>"; +export const BILLING_ACCOUNTS_ERROR = "<Error loading accounts>"; diff --git a/src/constants/workPeriods/apiPaymentStatus.js b/src/constants/workPeriods/apiPaymentStatus.js index 6693d5d..727211a 100644 --- a/src/constants/workPeriods/apiPaymentStatus.js +++ b/src/constants/workPeriods/apiPaymentStatus.js @@ -2,3 +2,4 @@ export const PENDING = "pending"; export const PARTIALLY_COMPLETED = "partially-completed"; export const COMPLETED = "completed"; export const CANCELLED = "cancelled"; +export const FAILED = "failed"; diff --git a/src/constants/workPeriods/paymentStatus.js b/src/constants/workPeriods/paymentStatus.js index 382d12f..cde9072 100644 --- a/src/constants/workPeriods/paymentStatus.js +++ b/src/constants/workPeriods/paymentStatus.js @@ -2,4 +2,5 @@ export const PAID = "PAID"; export const PENDING = "PENDING"; export const IN_PROGRESS = "IN_PROGRESS"; export const CANCELLED = "CANCELLED"; +export const FAILED = "FAILED"; export const UNDEFINED = "UNDEFINED"; diff --git a/src/root.component.jsx b/src/root.component.jsx index 9bec738..fe6e308 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -20,6 +20,7 @@ export default function Root() { from={APP_BASE_PATH} to={`${APP_BASE_PATH}/work-periods`} exact + noThrow /> <WorkPeriods path={`${APP_BASE_PATH}/work-periods`} /> <Freelancers path={`${APP_BASE_PATH}/freelancers`} /> diff --git a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss index a232c95..162480c 100644 --- a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss @@ -25,7 +25,8 @@ background: #9d41c9; } -.cancelled { +.cancelled, +.failed { background: #da0000; } diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx index bf0ce72..146d645 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -12,7 +12,7 @@ import { hideWorkPeriodDetails, setBillingAccount, setDetailsHidePastPeriods, - setDetailsLockWorkingDays, + // setDetailsLockWorkingDays, } from "store/actions/workPeriods"; import styles from "./styles.module.scss"; import { updateWorkPeriodBillingAccount } from "store/thunks/workPeriods"; @@ -25,22 +25,24 @@ import { useUpdateEffect } from "utils/hooks"; * @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 + * @param {boolean} props.isFailed whether the payments for the period has failed * @returns {JSX.Element} */ -const PeriodDetails = ({ className, details, isDisabled }) => { +const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { const dispatch = useDispatch(); const { periodId, rbId, jobName, - jobNameIsLoading, + jobNameError, billingAccountId, billingAccounts, - billingAccountsIsLoading, + billingAccountsError, + billingAccountsIsDisabled, periodsVisible, periodsIsLoading, hidePastPeriods, - lockWorkingDays, + // lockWorkingDays, } = details; const onHideDetailsBtnClick = useCallback(() => { @@ -54,12 +56,12 @@ const PeriodDetails = ({ className, details, isDisabled }) => { [dispatch, periodId] ); - const onChangeLockWorkingDays = useCallback( - (lock) => { - dispatch(setDetailsLockWorkingDays(periodId, lock)); - }, - [dispatch, periodId] - ); + // const onChangeLockWorkingDays = useCallback( + // (lock) => { + // dispatch(setDetailsLockWorkingDays(periodId, lock)); + // }, + // [dispatch, periodId] + // ); const onChangeBillingAccount = useCallback( (value) => { @@ -83,18 +85,14 @@ const PeriodDetails = ({ className, details, isDisabled }) => { 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)}> + <tr + className={cn( + styles.container, + { [styles.isFailed]: isFailed }, + className + )} + > {periodsIsLoading ? ( <td colSpan={8}> <div className={styles.loadingIndicator}>Loading...</div> @@ -108,14 +106,14 @@ const PeriodDetails = ({ className, details, isDisabled }) => { <div className={styles.label}>Job Name</div> <div className={cn(styles.jobName, { - [styles.jobNameError]: isFailedLoadingJobName, + [styles.jobNameError]: !!jobNameError, })} > - {jobNameIsLoading ? "Loading..." : jobName} + {jobName} </div> </div> </div> - <div className={styles.lockWorkingDaysSection}> + {/* <div className={styles.lockWorkingDaysSection}> <div className={styles.sectionLabel}>Lock Working Days</div> <Toggle size="small" @@ -124,15 +122,15 @@ const PeriodDetails = ({ className, details, isDisabled }) => { onChange={onChangeLockWorkingDays} isOn={lockWorkingDays} /> - </div> + </div> */} <div className={styles.billingAccountSection}> <div className={styles.sectionLabel}>Billing Account</div> <SelectField className={ - isFailedLoadingBilAccs ? styles.billingAccountError : "" + billingAccountsError ? styles.billingAccountsError : "" } id={`rb_bil_acc_${periodId}`} - isDisabled={isDisabledBilAccs} + isDisabled={billingAccountsIsDisabled} size="small" onChange={onChangeBillingAccount} options={billingAccounts} @@ -184,6 +182,7 @@ PeriodDetails.propTypes = { periodId: PT.string.isRequired, rbId: PT.string.isRequired, jobName: PT.string, + jobNameError: PT.string, jobNameIsLoading: PT.bool.isRequired, billingAccountId: PT.number.isRequired, billingAccounts: PT.arrayOf( @@ -192,6 +191,8 @@ PeriodDetails.propTypes = { value: PT.string.isRequired, }) ), + billingAccountsError: PT.string, + billingAccountsIsDisabled: PT.bool.isRequired, billingAccountsIsLoading: PT.bool.isRequired, periodsVisible: PT.array.isRequired, periodsIsLoading: PT.bool.isRequired, @@ -199,6 +200,7 @@ PeriodDetails.propTypes = { lockWorkingDays: PT.bool.isRequired, }).isRequired, isDisabled: PT.bool.isRequired, + isFailed: 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 index 6349a41..3410973 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodDetails/styles.module.scss @@ -1,7 +1,14 @@ @import "styles/mixins"; +@import "styles/variables"; .container { position: relative; + + &.isFailed { + td { + // background-color: $period-row-failed-bg-color; + } + } } .loadingIndicator { @@ -74,7 +81,7 @@ margin-top: 13px; } -.billingAccountError { +.billingAccountsError { color: #e90c5a; } diff --git a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx index 695ed57..a9d4309 100644 --- a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx @@ -3,15 +3,15 @@ import { useDispatch, useSelector } from "react-redux"; import debounce from "lodash/debounce"; import PT from "prop-types"; import cn from "classnames"; -// import SidebarSection from "components/SidebarSection"; +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 CheckboxList from "components/CheckboxList"; +import { PAYMENT_STATUS } from "constants/workPeriods"; import { getWorkPeriodsFilters } from "store/selectors/workPeriods"; import { resetWorkPeriodsFilters, - // setWorkPeriodsPaymentStatuses, + setWorkPeriodsPaymentStatuses, setWorkPeriodsUserHandle, } from "store/actions/workPeriods"; import { loadWorkPeriodsPage as loadWorkingPeriodsPage } from "store/thunks/workPeriods"; @@ -29,7 +29,7 @@ import styles from "./styles.module.scss"; const PeriodFilters = ({ className }) => { const dispatch = useDispatch(); const filters = useSelector(getWorkPeriodsFilters); - const { /*paymentStatuses,*/ userHandle } = filters; + const { paymentStatuses, userHandle } = filters; const onUserHandleChange = useCallback( (value) => { @@ -38,12 +38,12 @@ const PeriodFilters = ({ className }) => { [dispatch] ); - // const onPaymentStatusesChange = useCallback( - // (statuses) => { - // dispatch(setWorkPeriodsPaymentStatuses(statuses)); - // }, - // [dispatch] - // ); + const onPaymentStatusesChange = useCallback( + (statuses) => { + dispatch(setWorkPeriodsPaymentStatuses(statuses)); + }, + [dispatch] + ); const onClearFilter = useCallback(() => { dispatch(resetWorkPeriodsFilters()); @@ -74,14 +74,14 @@ const PeriodFilters = ({ className }) => { value={userHandle} /> </div> - {/* <SidebarSection label="Payment Status"> + <SidebarSection label="Payment Status"> <CheckboxList name="payment_status[]" onChange={onPaymentStatusesChange} options={PAYMENT_STATUS_OPTIONS} value={paymentStatuses} /> - </SidebarSection> */} + </SidebarSection> <div className={styles.buttons}> <Button className={styles.button} size="small" onClick={onClearFilter}> Clear Filter @@ -95,10 +95,11 @@ PeriodFilters.propTypes = { className: PT.string, }; -// const PAYMENT_STATUS_OPTIONS = [ -// { value: PAYMENT_STATUS.PENDING, label: "Pending" }, -// { value: PAYMENT_STATUS.PAID, label: "Paid" }, -// { value: PAYMENT_STATUS.IN_PROGRESS, label: "In Progress" }, -// ]; +const PAYMENT_STATUS_OPTIONS = [ + { value: PAYMENT_STATUS.PENDING, label: "Pending" }, + { value: PAYMENT_STATUS.PAID, label: "Paid" }, + { value: PAYMENT_STATUS.IN_PROGRESS, label: "In Progress" }, + // { value: PAYMENT_STATUS.FAILED, label: "Failed" }, +]; export default PeriodFilters; diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 92ca032..7b790ab 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -5,6 +5,7 @@ import cn from "classnames"; import debounce from "lodash/debounce"; import Checkbox from "components/Checkbox"; import IntegerField from "components/IntegerField"; +import ProjectName from "components/ProjectName"; import PaymentStatus from "../PaymentStatus"; import PeriodDetails from "../PeriodDetails"; import { @@ -17,26 +18,27 @@ import { } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; import { formatUserHandleLink, formatWeeklyRate } from "utils/formatters"; +import { stopPropagation } from "utils/misc"; 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.isFailed] whether the item should be highlighted as failed * @param {boolean} props.isSelected whether the item is selected * @param {Object} props.item object describing a working period * @param {Object} [props.details] object with working period details * @returns {JSX.Element} */ -const PeriodItem = ({ isDisabled = false, isSelected, item, details }) => { +const PeriodItem = ({ + isDisabled = false, + isFailed = false, + isSelected, + item, + details, +}) => { const dispatch = useDispatch(); const onToggleItem = useCallback( @@ -76,7 +78,10 @@ const PeriodItem = ({ isDisabled = false, isSelected, item, details }) => { return ( <> <tr - className={cn(styles.container, { [styles.hasDetails]: !!details })} + className={cn(styles.container, { + [styles.hasDetails]: !!details, + [styles.isFailed]: isFailed, + })} onClick={onToggleItemDetails} > <td className={styles.toggle}> @@ -102,7 +107,9 @@ const PeriodItem = ({ isDisabled = false, isSelected, item, details }) => { </a> </span> </td> - <td className={styles.teamName}>{item.projectId}</td> + <td className={styles.teamName}> + <ProjectName projectId={item.projectId} /> + </td> <td className={styles.startDate}>{item.startDate}</td> <td className={styles.endDate}>{item.endDate}</td> <td className={styles.weeklyRate}> @@ -123,7 +130,13 @@ const PeriodItem = ({ isDisabled = false, isSelected, item, details }) => { /> </td> </tr> - {details && <PeriodDetails details={details} isDisabled={isDisabled} />} + {details && ( + <PeriodDetails + details={details} + isDisabled={isDisabled} + isFailed={isFailed} + /> + )} </> ); }; @@ -131,6 +144,7 @@ const PeriodItem = ({ isDisabled = false, isSelected, item, details }) => { PeriodItem.propTypes = { className: PT.string, isDisabled: PT.bool, + isFailed: PT.bool, isSelected: PT.bool.isRequired, item: PT.shape({ id: PT.oneOfType([PT.number, PT.string]).isRequired, @@ -164,13 +178,6 @@ PeriodItem.propTypes = { ), 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 98a4561..bd8766b 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss @@ -1,4 +1,5 @@ @import "styles/mixins"; +@import "styles/variables"; .container { td { @@ -31,6 +32,12 @@ } } } + + &.isFailed { + td { + background-color: $period-row-failed-bg-color; + } + } } td.toggle { diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index e925a27..06ed6b5 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -2,11 +2,13 @@ import React from "react"; import { useSelector } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; +import ProjectNameContextProvider from "components/ProjectNameContextProvider"; import PeriodItem from "../PeriodItem"; import PeriodListHead from "../PeriodListHead"; import { getWorkPeriods, getWorkPeriodsDetails, + getWorkPeriodsFailed, getWorkPeriodsIsProcessingPayments, getWorkPeriodsSelected, } from "store/selectors/workPeriods"; @@ -22,31 +24,35 @@ import styles from "./styles.module.scss"; const PeriodList = ({ className }) => { const periods = useSelector(getWorkPeriods); const periodsDetails = useSelector(getWorkPeriodsDetails); + const periodsFailed = useSelector(getWorkPeriodsFailed); const periodsSelected = useSelector(getWorkPeriodsSelected); const isProcessingPayments = useSelector(getWorkPeriodsIsProcessingPayments); return ( - <div className={cn(styles.container, className)}> - <table className={styles.table}> - <thead> - <PeriodListHead /> - </thead> - <tbody> - <tr> - <td colSpan={8} className={styles.listTopMargin}></td> - </tr> - {periods.map((period) => ( - <PeriodItem - key={period.id} - isDisabled={isProcessingPayments} - isSelected={period.id in periodsSelected} - item={period} - details={periodsDetails[period.id]} - /> - ))} - </tbody> - </table> - </div> + <ProjectNameContextProvider> + <div className={cn(styles.container, className)}> + <table className={styles.table}> + <thead> + <PeriodListHead /> + </thead> + <tbody> + <tr> + <td colSpan={8} className={styles.listTopMargin}></td> + </tr> + {periods.map((period) => ( + <PeriodItem + key={period.id} + isDisabled={isProcessingPayments} + isFailed={period.id in periodsFailed} + isSelected={period.id in periodsSelected} + item={period} + details={periodsDetails[period.id]} + /> + ))} + </tbody> + </table> + </div> + </ProjectNameContextProvider> ); }; diff --git a/src/routes/WorkPeriods/components/PeriodsContentHeader/index.jsx b/src/routes/WorkPeriods/components/PeriodsContentHeader/index.jsx index 36c4417..d67c73b 100644 --- a/src/routes/WorkPeriods/components/PeriodsContentHeader/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsContentHeader/index.jsx @@ -23,6 +23,7 @@ const PeriodsContentHeader = () => { <ContentHeader className={styles.container}> <PageTitle text="Working Periods" /> <Button + className={styles.button} variant="contained" isDisabled={!hasSelectedItems || isProcessingPayments} onClick={onProcessPaymentsClick} diff --git a/src/routes/WorkPeriods/components/PeriodsContentHeader/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsContentHeader/styles.module.scss index b5709b1..5498721 100644 --- a/src/routes/WorkPeriods/components/PeriodsContentHeader/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodsContentHeader/styles.module.scss @@ -4,3 +4,7 @@ justify-content: space-between; align-items: center; } + +.button { + white-space: nowrap; +} diff --git a/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx index ad283bf..73b8e0c 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx @@ -17,15 +17,17 @@ const PeriodsHistory = ({ className, isDisabled, periodId, periods }) => { return ( <div className={cn(styles.container, className)}> <table> - {periods.map((period) => ( - <PeriodHistoryItem - key={period.id} - periodId={periodId} - isDisabled={isDisabled} - item={period} - currentStartDate={startDate} - /> - ))} + <tbody> + {periods.map((period) => ( + <PeriodHistoryItem + key={period.id} + periodId={periodId} + isDisabled={isDisabled} + item={period} + currentStartDate={startDate} + /> + ))} + </tbody> </table> </div> ); diff --git a/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx new file mode 100644 index 0000000..1202bfd --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx @@ -0,0 +1,62 @@ +import React, { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import PT from "prop-types"; +import cn from "classnames"; +import { + getWorkPeriodsIsSelectedAll, + getWorkPeriodsIsSelectedVisible, + getWorkPeriodsPageSize, + getWorkPeriodsTotalCount, +} from "store/selectors/workPeriods"; +import { toggleWorkingPeriodsAll } from "store/actions/workPeriods"; +import styles from "./styles.module.scss"; + +/** + * Displays messages about the number of selected periods and selection controls. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @returns {JSX.Element} + */ +const PeriodsSelectionMessage = ({ className }) => { + const isSelectedAll = useSelector(getWorkPeriodsIsSelectedAll); + const isSelectedVisible = useSelector(getWorkPeriodsIsSelectedVisible); + const pageSize = useSelector(getWorkPeriodsPageSize); + const totalCount = useSelector(getWorkPeriodsTotalCount); + const dispatch = useDispatch(); + + const onBtnClick = useCallback(() => { + dispatch(toggleWorkingPeriodsAll()); + }, [dispatch]); + + const infoText = isSelectedAll + ? `All ${totalCount} Records are selected. ` + : `All ${pageSize} Records on this page are selected. `; + const btnText = isSelectedAll + ? "Deselect" + : `Select all ${totalCount} Records`; + + return ( + <div className={cn(styles.container, className)}> + {isSelectedVisible && ( + <span className={styles.message}> + {infoText} + <span + className={styles.button} + onClick={onBtnClick} + role="button" + tabIndex={0} + > + {btnText} + </span> + </span> + )} + </div> + ); +}; + +PeriodsSelectionMessage.propTypes = { + className: PT.string, +}; + +export default PeriodsSelectionMessage; diff --git a/src/routes/WorkPeriods/components/PeriodsSelectionMessage/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/styles.module.scss new file mode 100644 index 0000000..bdb8984 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/styles.module.scss @@ -0,0 +1,19 @@ +@import "styles/mixins"; + +.container { + padding: 15px 15px 16px; + text-align: center; + + &:empty { + padding: 0; + } +} + +.message { + @include roboto-medium; +} + +.button { + color: #0d61bf; + cursor: pointer; +} diff --git a/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx index 03578a8..fd12b97 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx @@ -9,22 +9,17 @@ import ToastMessage from "components/ToastrMessage"; * @param {Object} props * @returns {JSX.Element} */ -const ToastPaymentsSuccess = ({ periods, remove }) => { +const ToastPaymentsError = ({ resourceCount, remove }) => { return ( <ToastMessage type="error" remove={remove}> - Failed to schedule payments for {periods.length} resources + Failed to schedule payments for {resourceCount} resources </ToastMessage> ); }; -ToastPaymentsSuccess.propTypes = { - periods: PT.arrayOf( - PT.shape({ - workPeriodId: PT.string.isRequired, - amount: PT.number.isRequired, - }) - ), +ToastPaymentsError.propTypes = { + resourceCount: PT.number.isRequired, remove: PT.func, }; -export default ToastPaymentsSuccess; +export default ToastPaymentsError; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx index 78387db..76357d0 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx @@ -10,22 +10,17 @@ import styles from "./styles.module.scss"; * @param {Object} props * @returns {JSX.Element} */ -const ToastPaymentsProcessing = ({ periods, remove }) => { +const ToastPaymentsProcessing = ({ resourceCount, remove }) => { return ( <ToastMessage className={styles.container} remove={remove}> <span className={styles.icon}></span> - Payment in progress for {periods.length} resources + Payment in progress for {resourceCount} resources </ToastMessage> ); }; ToastPaymentsProcessing.propTypes = { - periods: PT.arrayOf( - PT.shape({ - workPeriodId: PT.string.isRequired, - amount: PT.number.isRequired, - }) - ), + resourceCount: PT.number.isRequired, remove: PT.func, }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx index 61279aa..bba3215 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx @@ -9,21 +9,16 @@ import ToastMessage from "components/ToastrMessage"; * @param {Object} props * @returns {JSX.Element} */ -const ToastPaymentsSuccess = ({ periods, remove }) => { +const ToastPaymentsSuccess = ({ resourceCount, remove }) => { return ( <ToastMessage type="success" remove={remove}> - Payment scheduled for {periods.length} resources + Payment scheduled for {resourceCount} resources </ToastMessage> ); }; ToastPaymentsSuccess.propTypes = { - periods: PT.arrayOf( - PT.shape({ - workPeriodId: PT.string.isRequired, - amount: PT.number.isRequired, - }) - ), + resourceCount: PT.number.isRequired, remove: PT.func, }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx index 0297d34..256f86d 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx @@ -7,51 +7,70 @@ 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 + * @param {Object} props component properties * @returns {JSX.Element} */ -const ToastPaymentsWarning = ({ periodsSucceeded, periodsFailed, remove }) => { +const ToastPaymentsWarning = ({ + resourcesSucceeded, + resourcesSucceededCount, + resourcesFailed, + resourcesFailedCount, + 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 className={styles.sectionSucceeded}> + <div className={styles.sectionTitle}> + Payment scheduled for {resourcesSucceededCount} resources + </div> + {resourcesSucceeded && resourcesSucceeded.length && ( + <div className={styles.periodsSucceeded}> + {resourcesSucceeded.map((period) => ( + <div key={period.workPeriodId} className={styles.periodSucceeded}> + {period.workPeriodId} + </div> + ))} </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 className={styles.sectionFailed}> + <div className={styles.sectionTitle}> + Failed to schedule payment for {resourcesFailedCount} resources: + </div> + {resourcesFailed && resourcesFailed.length && ( + <div className={styles.periodsFailed}> + {resourcesFailed.map((period) => ( + <div key={period.workPeriodId} className={styles.periodFailed}> + {period.workPeriodId} ({period.error.code}):{" "} + {period.error.message} + </div> + ))} </div> - ))} + )} </div> </ToastMessage> ); }; ToastPaymentsWarning.propTypes = { - periodsSucceeded: PT.arrayOf( + resourcesSucceeded: PT.arrayOf( PT.shape({ workPeriodId: PT.string.isRequired, - amount: PT.number.isRequired, + amount: PT.number, }) ), - periodsFailed: PT.arrayOf( + resourcesSucceededCount: PT.number.isRequired, + resourcesFailed: PT.arrayOf( PT.shape({ workPeriodId: PT.string.isRequired, - amount: PT.number.isRequired, + amount: PT.number, error: PT.shape({ message: PT.string.isRequired, code: PT.number.isRequired, }), }) ), + resourcesFailedCount: PT.number.isRequired, remove: PT.func, }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss b/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss index d0ceca9..fcc4b34 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss +++ b/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss @@ -2,10 +2,11 @@ position: relative; } -.periodsSucceeded { +.sectionSucceeded { margin-bottom: 10px; + background: #1dcfa0; } -.periodFailed { +.sectionFailed { background: #ff7b7b; } diff --git a/src/routes/WorkPeriods/index.jsx b/src/routes/WorkPeriods/index.jsx index 30f15c9..3e9649f 100644 --- a/src/routes/WorkPeriods/index.jsx +++ b/src/routes/WorkPeriods/index.jsx @@ -9,9 +9,10 @@ import PeriodFilters from "./components/PeriodFilters"; import Periods from "./components/Periods"; import PeriodCount from "./components/PeriodCount"; import PeriodsPagination from "./components/PeriodsPagination"; -import styles from "./styles.module.scss"; +import PeriodsSelectionMessage from "./components/PeriodsSelectionMessage"; import PeriodWeekPicker from "./components/PeriodWeekPicker"; import { ADMIN_ROLES } from "../../constants"; +import styles from "./styles.module.scss"; /** * Displays route component for Working Days' route. @@ -25,7 +26,7 @@ const WorkPeriods = () => ( </Sidebar> <Content> <PeriodsContentHeader /> - <ContentBlock> + <ContentBlock className={styles.periodsBlock}> <div className={styles.periodsHeader}> <PeriodCount className={styles.periodCount} /> <PeriodWeekPicker className={styles.periodWeekPicker} /> @@ -34,6 +35,7 @@ const WorkPeriods = () => ( id="periods-pagination-top" /> </div> + <PeriodsSelectionMessage /> <Periods /> <div className={styles.periodsFooter}> <PeriodsPagination diff --git a/src/routes/WorkPeriods/styles.module.scss b/src/routes/WorkPeriods/styles.module.scss index 3a46a12..84a9490 100644 --- a/src/routes/WorkPeriods/styles.module.scss +++ b/src/routes/WorkPeriods/styles.module.scss @@ -1,6 +1,9 @@ .container { } +.periodsBlock { +} + .periodsHeader { display: flex; justify-content: space-between; @@ -16,6 +19,7 @@ } .periodCount { + white-space: nowrap; } .periodWeekPicker { diff --git a/src/routes/WorkPeriods/utils/toasts.jsx b/src/routes/WorkPeriods/utils/toasts.jsx new file mode 100644 index 0000000..f653c5f --- /dev/null +++ b/src/routes/WorkPeriods/utils/toasts.jsx @@ -0,0 +1,67 @@ +import React from "react"; +import { toastr } from "react-redux-toastr"; +import ToastPaymentsProcessing from "../components/ToastPaymentsProcessing"; +import ToastPaymentsSuccess from "../components/ToastPaymentsSuccess"; +import ToastPaymentsWarning from "../components/ToastPaymentsWarning"; +import ToastPaymentsError from "../components/ToastPaymentsError"; +import { TOAST_DEFAULT_TIMEOUT } from "constants/index.js"; + +const options = { + timeOut: TOAST_DEFAULT_TIMEOUT, + removeOnHover: false, + removeOnHoverTimeOut: TOAST_DEFAULT_TIMEOUT, + closeOnToastrClick: false, +}; + +/** + * Creates a redux toastr message denoting the start of payments processing. + * + * @param {number} resourceCount number of periods that were sent for processing + */ +export function makeToastPaymentsProcessing(resourceCount) { + const component = <ToastPaymentsProcessing resourceCount={resourceCount} />; + toastr.info("", { component, options }); +} + +/** + * Creates a redux toastr message denoting the successful scheduling of payments + * for the specified periods. + * + * @param {number} resourceCount number of periods for which payments were + * successfully scheduled + */ +export function makeToastPaymentsSuccess(resourceCount) { + const component = <ToastPaymentsSuccess resourceCount={resourceCount} />; + toastr.success("", { component, options }); +} + +/** + * Creates a redux toastr message denoting the partial success in shceduling + * payments for specified working periods. + * + * @param {Object} props warning toastr properties + * @param {number} props.resourcesSucceededCount the number of periods for which + * payments were successfully scheduled + * @param {number} props.resourcesFailedCount the number of periods for which + * payments were failed to be scheduled + * @param {Array} [props.resourcesSucceeded] periods for which payments were + * successfully scheduled + * @param {Array} [props.resourcesFailed] periods for which payments were failed + * to be scheduled + */ +export function makeToastPaymentsWarning(props) { + const component = <ToastPaymentsWarning {...props} />; + toastr.warning("", { component, options }); +} + +/** + * Creates redux toastr message showing the information about working + * periods for which the payments were failed to be scheduled. + * + * @param {number} resourceCount number of periods for which payments + * were failed to be scheduled + */ +export function makeToastPaymentsError(resourceCount) { + const component = <ToastPaymentsError resourceCount={resourceCount} />; + toastr.error("", { component, options }); +} diff --git a/src/services/workPeriods.js b/src/services/workPeriods.js index 9a85245..b4a5515 100644 --- a/src/services/workPeriods.js +++ b/src/services/workPeriods.js @@ -30,6 +30,18 @@ export const fetchJob = (jobId, source) => { ]; }; +/** + * Fetches project data by project id. + * + * @param {number} projectId project id + * @returns {Promise} + */ +export const fetchProject = (projectId) => { + return axios + .get(`${PROJECTS_API_URL}/${projectId}?fields=projectId,name`) + .then(extractResponseData); +}; + /** * Fetches billing accounts for specific project id. * @@ -120,3 +132,17 @@ export const patchWorkPeriodBillingAccount = (rbId, billingAccountId) => { export const postWorkPeriodsPayments = (payments) => { return axios.post(`${PAYMENTS_API_URL}`, payments).then(extractResponseData); }; + +/** + * Sends request to schedule payments for working periods satisfying + * the provided query. See + * https://topcoder-platform.github.io/taas-apis/#/WorkPeriodPayments/post_work_period_payments_query + * + * @param {Object} query query object + * @returns {Promise} + */ +export const postWorkPeriodsPaymentsAll = (query) => { + return axios + .post(`${PAYMENTS_API_URL}/query`, { query }) + .then(extractResponseData); +}; diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 064a7ae..511d5fe 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -2,6 +2,7 @@ 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_HIGHLIGHT_FAILED_PERIODS = "WP_HIGHLIGHT_FAILED_PERIODS"; export const WP_LOAD_PERIOD_DETAILS_PENDING = "WP_LOAD_PERIOD_DETAILS_PENDING"; export const WP_LOAD_PERIOD_DETAILS_ERROR = "WP_LOAD_PERIOD_DETAILS_ERROR"; export const WP_LOAD_PERIOD_DETAILS_SUCCESS = "WP_LOAD_PERIOD_DETAILS_SUCCESS"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index a57ad21..61920fe 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -211,6 +211,19 @@ export const selectWorkPeriods = (periods) => ({ payload: periods, }); +/** + * Creates an action that should result in deselecting working periods for which + * the payments were successfully scheduled and in highlighting those working + * periods for which the payments were failed to be scheduled. + * + * @param {Object} periods object with period ids as keys and booleans as values + * @returns {Object} + */ +export const highlightFailedWorkPeriods = (periods) => ({ + type: ACTION_TYPE.WP_HIGHLIGHT_FAILED_PERIODS, + payload: periods, +}); + /** * Creates an action denoting the changing of working periods' page number. * @@ -330,19 +343,23 @@ export const toggleWorkPeriod = (id) => ({ /** * Creates an action to toggle all working periods. * + * @param {?boolean} on whether to toggle all periods on or off * @returns {Object} */ -export const toggleWorkingPeriodsAll = () => ({ +export const toggleWorkingPeriodsAll = (on = null) => ({ type: ACTION_TYPE.WP_TOGGLE_PERIODS_ALL, + payload: on, }); /** * Creates an action to toggle all visible working periods. * + * @param {?boolean} on whether to toggle all visible periods on or off * @returns {Object} */ -export const toggleWorkingPeriodsVisible = () => ({ +export const toggleWorkingPeriodsVisible = (on = null) => ({ type: ACTION_TYPE.WP_TOGGLE_PERIODS_VISIBLE, + payload: on, }); /** diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 891c876..706ca1a 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -4,12 +4,18 @@ import { SORT_BY_DEFAULT, SORT_ORDER_DEFAULT, PAYMENT_STATUS, + JOB_NAME_ERROR, + BILLING_ACCOUNTS_NONE, + JOB_NAME_LOADING, + BILLING_ACCOUNTS_LOADING, + BILLING_ACCOUNTS_ERROR, } from "constants/workPeriods"; import { filterPeriodsByStartDate, getWeekByDate, updateOptionMap, } from "utils/misc"; +import { createAssignedBillingAccountOption } from "utils/workPeriods"; const initPagination = () => ({ totalCount: 0, @@ -39,10 +45,15 @@ const initPeriodDetails = ( periodId, rbId, cancelSource, - jobName: "Loading...", + jobName: JOB_NAME_LOADING, + jobNameError: null, jobNameIsLoading: true, billingAccountId, - billingAccounts: [{ value: billingAccountId, label: "Loading..." }], + billingAccounts: [ + { value: billingAccountId, label: BILLING_ACCOUNTS_LOADING }, + ], + billingAccountsError: null, + billingAccountsIsDisabled: true, billingAccountsIsLoading: true, periods: [], periodsVisible: [], @@ -56,6 +67,7 @@ const initialState = { cancelSource: cancelSourceDummy, periods: [], periodsDetails: {}, + periodsFailed: {}, periodsSelected: {}, isSelectedPeriodsAll: false, isSelectedPeriodsVisible: false, @@ -85,7 +97,10 @@ const actionHandlers = { error: null, periods: [], periodsDetails: {}, + periodsFailed: {}, periodsSelected: {}, + isSelectedPeriodsAll: false, + isSelectedPeriodsVisible: false, pagination: pageNumber === state.pagination.pageNumber ? state.pagination @@ -126,6 +141,33 @@ const actionHandlers = { periodsDetails, }; }, + [ACTION_TYPE.WP_HIGHLIGHT_FAILED_PERIODS]: (state, periods) => { + const periodIds = Object.keys(periods); + if (!periodIds.length) { + return state; + } + let isSelectedPeriodsAll = state.isSelectedPeriodsAll; + let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; + const periodsFailed = { ...state.periodsFailed }; + const periodsSelected = { ...state.periodsSelected }; + for (let periodId of periodIds) { + if (periods[periodId]) { + periodsFailed[periodId] = true; + periodsSelected[periodId] = true; + } else { + isSelectedPeriodsAll = false; + isSelectedPeriodsVisible = false; + delete periodsSelected[periodId]; + } + } + return { + ...state, + isSelectedPeriodsAll, + isSelectedPeriodsVisible, + periodsFailed, + periodsSelected, + }; + }, [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_PENDING]: ( state, { periodId, rbId, billingAccountId, cancelSource } @@ -192,7 +234,12 @@ const actionHandlers = { // Period details may be removed at this point so we must handle this case. return state; } - periodDetails = { ...periodDetails, jobName, jobNameIsLoading: false }; + periodDetails = { + ...periodDetails, + jobName, + jobNameError: null, + jobNameIsLoading: false, + }; if (!periodDetails.billingAccountsIsLoading) { periodDetails.cancelSource = null; } @@ -203,7 +250,6 @@ const actionHandlers = { }; }, [ACTION_TYPE.WP_LOAD_JOB_NAME_ERROR]: (state, { periodId, message }) => { - console.error(message); const periodsDetails = { ...state.periodsDetails }; let periodDetails = periodsDetails[periodId]; if (!periodDetails) { @@ -211,7 +257,8 @@ const actionHandlers = { } periodDetails = { ...periodDetails, - jobName: "Error", + jobName: JOB_NAME_ERROR, + jobNameError: message, jobNameIsLoading: false, }; if (!periodDetails.billingAccountsIsLoading) { @@ -233,15 +280,17 @@ const actionHandlers = { // Period details may be removed at this point so we must handle this case. return state; } - let billingAccountId = periodDetails.billingAccountId; + let billingAccountsIsDisabled = false; + let accountId = periodDetails.billingAccountId; if (!accounts.length) { - accounts.push({ value: -1, label: "No Accounts Available" }); - billingAccountId = -1; + accounts.push({ value: accountId, label: BILLING_ACCOUNTS_NONE }); + billingAccountsIsDisabled = true; } periodDetails = { ...periodDetails, - billingAccountId, billingAccounts: accounts, + billingAccountsError: null, + billingAccountsIsDisabled, billingAccountsIsLoading: false, }; if (!periodDetails.jobNameIsLoading) { @@ -257,17 +306,25 @@ const actionHandlers = { state, { periodId, message } ) => { - console.error(message); const periodsDetails = { ...state.periodsDetails }; let periodDetails = periodsDetails[periodId]; if (!periodDetails) { return state; } + let billingAccounts = []; + let billingAccountsIsDisabled = true; + let accountId = periodDetails.billingAccountId; + if (accountId) { + billingAccounts.push(createAssignedBillingAccountOption(accountId)); + billingAccountsIsDisabled = false; + } else { + billingAccounts.push({ value: accountId, label: BILLING_ACCOUNTS_ERROR }); + } periodDetails = { ...periodDetails, - billingAccounts: [ - { value: periodDetails.billingAccountId, label: "Error" }, - ], + billingAccounts, + billingAccountsError: message, + billingAccountsIsDisabled, billingAccountsIsLoading: false, }; if (!periodDetails.jobNameIsLoading) { @@ -386,16 +443,25 @@ const actionHandlers = { }; }, [ACTION_TYPE.WP_SELECT_PERIODS]: (state, periods) => { + let isSelectedPeriodsAll = state.isSelectedPeriodsAll; + let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; let periodsSelected = { ...state.periodsSelected }; for (let periodId in periods) { if (periods[periodId] === true) { periodsSelected[periodId] = true; } else { + isSelectedPeriodsAll = false; + isSelectedPeriodsVisible = false; delete periodsSelected[periodId]; } } + if (Object.keys(periodsSelected).length === state.pagination.pageSize) { + isSelectedPeriodsVisible = true; + } return { ...state, + isSelectedPeriodsAll, + isSelectedPeriodsVisible, periodsSelected, }; }, @@ -446,13 +512,18 @@ const actionHandlers = { ), }, }), - [ACTION_TYPE.WP_SET_USER_HANDLE]: (state, userHandle) => ({ - ...state, - filters: { - ...state.filters, - userHandle, - }, - }), + [ACTION_TYPE.WP_SET_USER_HANDLE]: (state, userHandle) => { + if (userHandle === state.filters.userHandle) { + return state; + } + return { + ...state, + filters: { + ...state.filters, + userHandle, + }, + }; + }, [ACTION_TYPE.WP_SET_WORKING_DAYS]: (state, { periodId, workingDays }) => { const oldPeriods = state.periods; const periods = []; @@ -478,6 +549,9 @@ const actionHandlers = { const isSelected = !periodsSelected[periodId]; if (isSelected) { periodsSelected[periodId] = true; + if (Object.keys(periodsSelected).length === state.pagination.pageSize) { + isSelectedPeriodsVisible = true; + } } else { isSelectedPeriodsAll = false; isSelectedPeriodsVisible = false; @@ -490,8 +564,8 @@ const actionHandlers = { isSelectedPeriodsVisible, }; }, - [ACTION_TYPE.WP_TOGGLE_PERIODS_ALL]: (state) => { - const isSelected = !state.isSelectedPeriodsAll; + [ACTION_TYPE.WP_TOGGLE_PERIODS_ALL]: (state, on) => { + const isSelected = on === null ? !state.isSelectedPeriodsAll : on; const periodsSelected = {}; if (isSelected) { for (let period of state.periods) { @@ -505,8 +579,8 @@ const actionHandlers = { isSelectedPeriodsVisible: isSelected, }; }, - [ACTION_TYPE.WP_TOGGLE_PERIODS_VISIBLE]: (state) => { - const isSelected = !state.isSelectedPeriodsVisible; + [ACTION_TYPE.WP_TOGGLE_PERIODS_VISIBLE]: (state, on) => { + const isSelected = on === null ? !state.isSelectedPeriodsVisible : on; const periodsSelected = {}; if (isSelected) { for (let period of state.periods) { @@ -520,10 +594,18 @@ const actionHandlers = { isSelectedPeriodsVisible: isSelected, }; }, - [ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS]: (state, on) => ({ - ...state, - isProcessingPayments: on === null ? !state.isProcessingPayments : on, - }), + [ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS]: (state, on) => { + let periodsFailed = state.periodsFailed; + let isProcessingPayments = on === null ? !state.isProcessingPayments : on; + if (isProcessingPayments) { + periodsFailed = {}; + } + return { + ...state, + periodsFailed, + isProcessingPayments, + }; + }, }; export default reducer; diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js index 3719420..d0ad0ab 100644 --- a/src/store/selectors/workPeriods.js +++ b/src/store/selectors/workPeriods.js @@ -23,6 +23,15 @@ export const getWorkPeriods = (state) => state.workPeriods.periods; export const getWorkPeriodsDetails = (state) => state.workPeriods.periodsDetails; +/** + * Returns an object which has working periods' ids for which the payments + * were failed to be scheduled as keys. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getWorkPeriodsFailed = (state) => state.workPeriods.periodsFailed; + /** * Returns an object with working periods' ids as keys and booleans showing * whether the period is selected as values. @@ -50,6 +59,9 @@ export const getWorkPeriodsSorting = (state) => state.workPeriods.sorting; export const getWorkPeriodsPagination = (state) => state.workPeriods.pagination; +export const getWorkPeriodsPageSize = (state) => + state.workPeriods.pagination.pageSize; + export const getWorkPeriodsCount = (state) => state.workPeriods.periods.length; export const getWorkPeriodsTotalCount = (state) => diff --git a/src/store/thunks/workPeriods.jsx b/src/store/thunks/workPeriods.js similarity index 62% rename from src/store/thunks/workPeriods.jsx rename to src/store/thunks/workPeriods.js index 2db85ff..7712130 100644 --- a/src/store/thunks/workPeriods.jsx +++ b/src/store/thunks/workPeriods.js @@ -1,11 +1,4 @@ -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"; @@ -15,6 +8,7 @@ import { DATE_FORMAT_API, PAYMENT_STATUS_MAP, FIELDS_QUERY, + JOB_NAME_NONE, } from "constants/workPeriods"; import { extractJobName, @@ -27,6 +21,13 @@ import { normalizeDetailsPeriodItems, normalizePeriodItems, } from "utils/workPeriods"; +import { makeToast } from "components/ToastrMessage"; +import { + makeToastPaymentsProcessing, + makeToastPaymentsSuccess, + makeToastPaymentsWarning, + makeToastPaymentsError, +} from "routes/WorkPeriods/utils/toasts"; import { RESOURCE_BOOKING_STATUS } from "constants/index.js"; /** @@ -73,12 +74,7 @@ export const loadWorkPeriodsPage = 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, + ["workPeriods.paymentStatus"]: paymentStatuses, }); dispatch(actions.loadWorkPeriodsPagePending(cancelSource, pageNumber)); let totalCount, periods, pageCount; @@ -139,16 +135,50 @@ export const toggleWorkPeriodDetails = source ) ); - const [rbPromise] = services.fetchWorkPeriods(period.rbId, source); - const [jobNamePromise] = services.fetchJob(period.jobId, source); + + if (period.jobId) { + const [jobNamePromise] = services.fetchJob(period.jobId, source); + jobNamePromise + .then((data) => { + const jobName = extractJobName(data); + dispatch(actions.loadJobNameSuccess(period.id, jobName)); + }) + .catch((error) => { + if (!axios.isCancel(error)) { + dispatch(actions.loadJobNameError(period.id, error.toString())); + } + }); + } else { + dispatch(actions.loadJobNameSuccess(period.id, JOB_NAME_NONE)); + } + const [bilAccsPromise] = services.fetchBillingAccounts( period.projectId, source ); + bilAccsPromise + .then((data) => { + const periodsDetails = selectors.getWorkPeriodsDetails(getState()); + const periodDetails = periodsDetails[period.id]; + const billingAccountId = + (periodDetails && periodDetails.billingAccountId) || + period.billingAccountId; + const accounts = normalizeBillingAccounts(data, billingAccountId); + dispatch(actions.loadBillingAccountsSuccess(period.id, accounts)); + }) + .catch((error) => { + if (!axios.isCancel(error)) { + dispatch( + actions.loadBillingAccountsError(period.id, error.toString()) + ); + } + }); + + const [periodsPromise] = services.fetchWorkPeriods(period.rbId, source); let details = null; let errorMessage = null; try { - const data = await rbPromise; + const data = await periodsPromise; const periods = normalizeDetailsPeriodItems(data); details = { periods }; } catch (error) { @@ -162,43 +192,6 @@ export const toggleWorkPeriodDetails = 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)); @@ -249,16 +242,71 @@ export const updateWorkPeriodWorkingDays = */ export const processPayments = async (dispatch, getState) => { dispatch(actions.toggleWorkPeriodsProcessingPeyments(true)); + const isSelectedAll = selectors.getWorkPeriodsIsSelectedAll(getState()); + if (isSelectedAll) { + processPaymentsAll(dispatch, getState); + } else { + processPaymentsSpecific(dispatch, getState); + } + dispatch(actions.toggleWorkPeriodsProcessingPeyments(false)); +}; + +const processPaymentsAll = async (dispatch, getState) => { + const state = getState(); + const filters = selectors.getWorkPeriodsFilters(state); + const [startDate] = filters.dateRange; + const paymentStatuses = replaceItems( + Object.keys(filters.paymentStatuses), + PAYMENT_STATUS_MAP + ); + const totalCount = selectors.getWorkPeriodsTotalCount(state); + makeToastPaymentsProcessing(totalCount); + const promise = services.postWorkPeriodsPaymentsAll({ + status: RESOURCE_BOOKING_STATUS.PLACED, + ["workPeriods.userHandle"]: filters.userHandle, + ["workPeriods.startDate"]: startDate.format(DATE_FORMAT_API), + ["workPeriods.paymentStatus"]: paymentStatuses.join(","), + }); + let data = null; + let errorMessage = null; + try { + data = await promise; + } catch (error) { + errorMessage = error.toString(); + } + dispatch(actions.toggleWorkingPeriodsAll(false)); + if (data) { + const { totalSuccess, totalError } = data; + const resourcesSucceededCount = +totalSuccess; + const resourcesFailedCount = +totalError; + if (resourcesSucceededCount) { + if (resourcesFailedCount) { + makeToastPaymentsWarning({ + resourcesSucceededCount, + resourcesFailedCount, + }); + } else { + makeToastPaymentsSuccess(resourcesSucceededCount); + } + } else { + makeToastPaymentsError(resourcesFailedCount); + } + } else { + makeToast(errorMessage); + } +}; + +const processPaymentsSpecific = async (dispatch, getState) => { 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 }); + payments.push({ workPeriodId: period.id }); } } - makeProcessingToast(payments); + makeToastPaymentsProcessing(payments.length); let results = null; let errorMessage = null; try { @@ -267,70 +315,35 @@ export const processPayments = async (dispatch, getState) => { errorMessage = error.toString(); } if (results) { - const periodsToDeselect = {}; - const periodsSucceeded = []; - const periodsFailed = []; + const periodsToHighlight = {}; + const resourcesSucceeded = []; + const resourcesFailed = []; for (let result of results) { - if ("error" in result) { - periodsFailed.push(result); + let isFailed = "error" in result; + periodsToHighlight[result.workPeriodId] = isFailed; + if (isFailed) { + resourcesFailed.push(result); } else { - periodsToDeselect[result.workPeriodId] = false; - periodsSucceeded.push(result); + resourcesSucceeded.push(result); } } - dispatch(actions.selectWorkPeriods(periodsToDeselect)); - if (periodsSucceeded.length) { - if (periodsFailed.length) { - makeWarningToast(periodsSucceeded, periodsFailed); + // highlights failed periods and deselects successful periods + dispatch(actions.highlightFailedWorkPeriods(periodsToHighlight)); + if (resourcesSucceeded.length) { + if (resourcesFailed.length) { + makeToastPaymentsWarning({ + resourcesSucceeded, + resourcesSucceededCount: resourcesSucceeded.length, + resourcesFailed, + resourcesFailedCount: resourcesFailed.length, + }); } else { - makeSuccessToast(periodsSucceeded); + makeToastPaymentsSuccess(resourcesSucceeded.length); } } else { - makeErrorToast(periodsFailed); + makeToastPaymentsError(resourcesFailed.length); } } 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 0dc3193..c707bbb 100644 --- a/src/styles/toastr.scss +++ b/src/styles/toastr.scss @@ -3,7 +3,7 @@ .redux-toastr { position: absolute; - left: $sidebar-width; + left: 0; top: 0; right: 0; margin: 0; @@ -12,16 +12,26 @@ height: auto; background: transparent; + @include desktop { + left: $sidebar-width; + } + > div { position: absolute; - left: 22px; + left: 10px; top: 24px; - right: 14px; + right: 10px; margin: 0; border: none; padding: 0; height: auto; background: transparent; + + @include desktop { + left: 22px; + top: 24px; + right: 14px; + } } .top-right { diff --git a/src/styles/variables/_colors.scss b/src/styles/variables/_colors.scss index 9e8c6ba..77b0bd3 100644 --- a/src/styles/variables/_colors.scss +++ b/src/styles/variables/_colors.scss @@ -23,3 +23,5 @@ $control-disabled-text-color: lighten( $checkbox-bg-color: $primary-light-color; $toggle-active-bg-color: $primary-light-color; + +$period-row-failed-bg-color: #f8dde3; diff --git a/src/utils/misc.js b/src/utils/misc.js index 97d8215..6e30d8a 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -67,6 +67,15 @@ export function replaceItems(array, map) { return result; } +/** + * Stops event propagation. + * + * @param {Object} event event object + */ +export function stopPropagation(event) { + event.stopPropagation(); +} + /** * This function takes keys referring to truthy values in `newOptions` * and adds them to `oldOptions` returning a new object. @@ -113,9 +122,7 @@ export const buildRequestQuery = (params, paramNames) => { } if (Array.isArray(paramValue)) { if (paramValue.length) { - queryParams.push( - paramValue.map((value) => `${paramName}[]=${value}`).join("&") - ); + queryParams.push(`${paramName}=${paramValue.join(",")}`); } } else { queryParams.push(`${paramName}=${paramValue}`); @@ -135,6 +142,6 @@ export const extractJobName = (data) => data.title; export const extractResponseData = (response) => response.data; -export function stopPropagation(event) { - event.stopPropagation(); -} +export const increment = (value) => value + 1; + +export const noop = () => {}; diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 253d7fb..8ed10a2 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -55,14 +55,15 @@ export function normalizeBillingAccounts(accounts, accountId = -1) { }); } if (!hasSelectedAccount && accountId > 0) { - accs.unshift({ - value: accountId, - label: `<Assigned Account> (${accountId})`, - }); + accs.unshift(createAssignedBillingAccountOption(accountId)); } return accs; } +export function createAssignedBillingAccountOption(accountId) { + return { value: accountId, label: `<Assigned Account> (${accountId})` }; +} + export function normalizeDetailsPeriodItems(items) { const periods = []; for (let item of items) {