diff --git a/src/components/Popup/index.jsx b/src/components/Popup/index.jsx new file mode 100644 index 0000000..dd22d5b --- /dev/null +++ b/src/components/Popup/index.jsx @@ -0,0 +1,55 @@ +import React, { useState } from "react"; +import { usePopper } from "react-popper"; +import PT from "prop-types"; +import cn from "classnames"; +import compStyles from "./styles.module.scss"; + +/** + * Displays a popup near the reference element. + * + * @param {Object} props component properties + * @param {any} [props.children] child nodes + * @param {string} [props.className] class name to be added to root element + * @param {Object} props.referenceElement reference element + * @param {'absolute'|'fixed'} [props.strategy] positioning strategy + * @returns {JSX.Element} + */ +const Popup = ({ + children, + className, + referenceElement, + strategy = "absolute", +}) => { + const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom", + strategy, + modifiers: [ + { name: "arrow", options: { element: arrowElement, padding: 10 } }, + { name: "offset", options: { offset: [0, 5] } }, + { name: "preventOverflow", options: { padding: 15 } }, + ], + }); + + return ( +
+ {children} +
+
+ ); +}; + +Popup.propTypes = { + children: PT.node, + className: PT.string, + referenceElement: PT.object.isRequired, + strategy: PT.oneOf(["absolute", "fixed"]), +}; + +export default Popup; diff --git a/src/components/Popup/styles.module.scss b/src/components/Popup/styles.module.scss new file mode 100644 index 0000000..6a800ea --- /dev/null +++ b/src/components/Popup/styles.module.scss @@ -0,0 +1,13 @@ +@import "styles/variables"; + +.container { + z-index: 10; + border-radius: 8px; + padding: $popover-padding; + background: #fff; + box-shadow: $popover-box-shadow; + + :global(.popup-arrow) { + display: none; + } +} diff --git a/src/components/SearchHandleField/index.jsx b/src/components/SearchHandleField/index.jsx index 804b919..b4e9c5d 100644 --- a/src/components/SearchHandleField/index.jsx +++ b/src/components/SearchHandleField/index.jsx @@ -1,19 +1,67 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useRef, useState } from "react"; import PT from "prop-types"; import cn from "classnames"; -import AsyncSelect from "react-select/async"; +import throttle from "lodash/throttle"; +import Select, { components } from "react-select"; import { getMemberSuggestions } from "services/teams"; +import { useUpdateEffect } from "utils/hooks"; import styles from "./styles.module.scss"; +const loadingMessage = () => "Loading..."; + +const noOptionsMessage = () => "No suggestions"; + +function MenuList(props) { + let focusedOption = props.focusedOption; + focusedOption = props.selectProps.isMenuFocused + ? focusedOption + : props.getValue()[0]; + const setIsMenuFocused = props.selectProps.setIsMenuFocused; + + const onMouseEnter = useCallback(() => { + setIsMenuFocused(true); + }, [setIsMenuFocused]); + + return ( +
+ +
+ ); +} +MenuList.propTypes = { + focusedOption: PT.object, + getValue: PT.func, + selectProps: PT.shape({ + isMenuFocused: PT.oneOfType([PT.bool, PT.number]), + setIsMenuFocused: PT.func, + }), +}; + +function Option(props) { + return ( + + ); +} +Option.propTypes = { + isFocused: PT.bool, + isSelected: PT.bool, + selectProps: PT.shape({ + isMenuFocused: PT.oneOfType([PT.bool, PT.number]), + }), +}; + const selectComponents = { DropdownIndicator: () => null, + ClearIndicator: () => null, IndicatorSeparator: () => null, + MenuList, + Option, }; -const loadingMessage = () => "Loading..."; - -const noOptionsMessage = () => "No suggestions"; - /** * Displays search input field. * @@ -23,7 +71,9 @@ const noOptionsMessage = () => "No suggestions"; * @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 {function} props.onChange function called when value changes + * @param {function} [props.onInputChange] function called when input value changes + * @param {function} [props.onBlur] function called on input blur * @param {string} props.value input value * @returns {JSX.Element} */ @@ -33,33 +83,117 @@ const SearchHandleField = ({ name, size = "medium", onChange, + onInputChange, + onBlur, placeholder, value, }) => { - const onValueChange = useCallback( - (option, { action }) => { - if (action === "clear") { - onChange(""); - } else { + const [inputValue, setInputValue] = useState(value); + const [isLoading, setIsLoading] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isMenuFocused, setIsMenuFocused] = useState(false); + const [options, setOptions] = useState([]); + const isChangeAppliedRef = useRef(false); + + const onValueChange = (option, { action }) => { + if (action === "input-change" || action === "select-option") { + if (isMenuFocused && !isLoading && option) { + isChangeAppliedRef.current = true; + setIsMenuFocused(false); + setIsMenuOpen(false); + setIsLoading(false); onChange(option.value); } - }, - [onChange] - ); + } else if (action === "clear") { + isChangeAppliedRef.current = true; + setIsMenuFocused(false); + setIsMenuOpen(false); + setIsLoading(false); + onChange(""); + } + }; - const onInputChange = useCallback( + const onInputValueChange = useCallback( (value, { action }) => { if (action === "input-change") { - onChange(value); + isChangeAppliedRef.current = false; + setIsMenuFocused(false); + setInputValue(value); + onInputChange && onInputChange(value); } }, - [onChange] + [onInputChange] + ); + + const onKeyDown = (event) => { + const key = event.key; + if (key === "Enter" || key === "Escape") { + if (!isMenuFocused || isLoading) { + isChangeAppliedRef.current = true; + setIsMenuFocused(false); + setIsMenuOpen(false); + setIsLoading(false); + onChange(inputValue); + } + } else if (key === "ArrowDown") { + if (!isMenuFocused) { + event.preventDefault(); + event.stopPropagation(); + setIsMenuFocused(true); + } + } else if (key === "Backspace") { + if (!inputValue) { + event.preventDefault(); + event.stopPropagation(); + } + } + }; + + const onSelectBlur = () => { + setIsMenuFocused(false); + setIsMenuOpen(false); + onChange(inputValue); + onBlur && onBlur(); + }; + + const loadOptions = useCallback( + throttle( + async (value) => { + if (!isChangeAppliedRef.current) { + setIsLoading(true); + const options = await loadSuggestions(value); + if (!isChangeAppliedRef.current) { + setOptions(options); + setIsLoading(false); + setIsMenuOpen(true); + } + } + }, + 300, + { leading: false } + ), + [] ); + useUpdateEffect(() => { + setInputValue(value); + }, [value]); + + useUpdateEffect(() => { + loadOptions(inputValue); + }, [inputValue]); + return ( -
+
-
); }; -const loadSuggestions = async (inputVal) => { +const loadSuggestions = async (inputValue) => { let options = []; - if (inputVal.length < 3) { + if (inputValue.length < 3) { return options; } try { - const res = await getMemberSuggestions(inputVal); - const users = res.data.result.content; + const res = await getMemberSuggestions(inputValue); + const users = res.data.result.content.slice(0, 100); + let match = null; for (let i = 0, len = users.length; i < len; i++) { let value = users[i].handle; - options.push({ value, label: value }); + if (value === inputValue) { + match = { value, label: value }; + } else { + options.push({ value, label: value }); + } + } + if (match) { + options.unshift(match); } } catch (error) { console.error(error); @@ -108,6 +253,8 @@ SearchHandleField.propTypes = { size: PT.oneOf(["medium", "small"]), name: PT.string.isRequired, onChange: PT.func.isRequired, + onInputChange: PT.func, + onBlur: PT.func, placeholder: PT.string, value: PT.oneOfType([PT.number, PT.string]), }; diff --git a/src/components/SearchHandleField/styles.module.scss b/src/components/SearchHandleField/styles.module.scss index 1cc06f8..efea7a9 100644 --- a/src/components/SearchHandleField/styles.module.scss +++ b/src/components/SearchHandleField/styles.module.scss @@ -41,6 +41,10 @@ input.input { } } +.menuList { + display: block; +} + .select { z-index: 1; position: relative; @@ -157,6 +161,17 @@ input.input { } } + :global(.custom__option--is-selected) { + background-color: $primary-text-color !important; + color: #fff; + } + + :global(.custom__clear-indicator) { + display: none; + } +} + +.isMenuFocused { :global(.custom__option--is-focused) { background-color: $primary-text-color !important; color: #fff; diff --git a/src/constants/index.js b/src/constants/index.js index 198dbbb..0f93a11 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -4,6 +4,10 @@ export { PLATFORM_WEBSITE_URL, TOPCODER_WEBSITE_URL }; export const APP_BASE_PATH = "/taas-admin"; +export const WORK_PERIODS_PATH = `${APP_BASE_PATH}/work-periods`; + +export const FREELANCERS_PATH = `${APP_BASE_PATH}/freelancers`; + export const TAAS_BASE_PATH = "/taas"; export const ADMIN_ROLES = ["bookingmanager", "administrator"]; diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index ec45ca8..89d5db4 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -1,12 +1,20 @@ // @ts-ignore import { API } from "../../config"; +import * as API_CHALLENGE_PAYMENT_STATUS from "./workPeriods/apiChallengePaymentStatus"; import * as API_PAYMENT_STATUS from "./workPeriods/apiPaymentStatus"; import * as API_SORT_BY from "./workPeriods/apiSortBy"; import * as SORT_BY from "./workPeriods/sortBy"; import * as SORT_ORDER from "./workPeriods/sortOrder"; import * as PAYMENT_STATUS from "./workPeriods/paymentStatus"; -export { API_PAYMENT_STATUS, API_SORT_BY, SORT_BY, SORT_ORDER, PAYMENT_STATUS }; +export { + API_CHALLENGE_PAYMENT_STATUS, + API_PAYMENT_STATUS, + API_SORT_BY, + SORT_BY, + SORT_ORDER, + PAYMENT_STATUS, +}; // resource bookings API url export const RB_API_URL = `${API.V5}/resourceBookings`; @@ -19,7 +27,7 @@ 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 sorting. -export const REQUIRED_FIELDS = [ +export const API_REQUIRED_FIELDS = [ "id", "jobId", "projectId", @@ -37,18 +45,25 @@ export const REQUIRED_FIELDS = [ "workPeriods.paymentTotal", "workPeriods.daysWorked", "workPeriods.daysPaid", + "workPeriods.payments.amount", + "workPeriods.payments.challengeId", + "workPeriods.payments.days", + "workPeriods.payments.id", + "workPeriods.payments.memberRate", + "workPeriods.payments.status", + "workPeriods.payments.statusDetails", ]; // Valid parameter names for requests. -export const QUERY_PARAM_NAMES = [ +export const API_QUERY_PARAM_NAMES = [ "fields", "page", "perPage", "sortBy", "sortOrder", -].concat(REQUIRED_FIELDS); +].concat(API_REQUIRED_FIELDS); -export const FIELDS_QUERY = REQUIRED_FIELDS.join(","); +export const API_FIELDS_QUERY = API_REQUIRED_FIELDS.join(","); export const SORT_BY_DEFAULT = SORT_BY.USER_HANDLE; @@ -65,11 +80,15 @@ export const SORT_BY_MAP = { }; export const PAYMENT_STATUS_LABELS = { - [PAYMENT_STATUS.NO_DAYS]: "No Days", + [PAYMENT_STATUS.CANCELLED]: "Cancelled", [PAYMENT_STATUS.COMPLETED]: "Completed", + [PAYMENT_STATUS.FAILED]: "Failed", + [PAYMENT_STATUS.IN_PROGRESS]: "In Progress", + [PAYMENT_STATUS.NO_DAYS]: "No Days", [PAYMENT_STATUS.PARTIALLY_COMPLETED]: "Partially Completed", [PAYMENT_STATUS.PENDING]: "Pending", - [PAYMENT_STATUS.IN_PROGRESS]: "In Progress", + [PAYMENT_STATUS.SCHEDULED]: "Scheduled", + [PAYMENT_STATUS.UNDEFINED]: "NA", }; export const PAYMENT_STATUS_MAP = { @@ -90,6 +109,25 @@ export const API_PAYMENT_STATUS_MAP = (function () { return obj; })(); +export const API_CHALLENGE_PAYMENT_STATUS_MAP = { + [API_CHALLENGE_PAYMENT_STATUS.CANCELLED]: PAYMENT_STATUS.CANCELLED, + [API_CHALLENGE_PAYMENT_STATUS.COMPLETED]: PAYMENT_STATUS.COMPLETED, + [API_CHALLENGE_PAYMENT_STATUS.FAILED]: PAYMENT_STATUS.FAILED, + [API_CHALLENGE_PAYMENT_STATUS.IN_PROGRESS]: PAYMENT_STATUS.IN_PROGRESS, + [API_CHALLENGE_PAYMENT_STATUS.SCHEDULED]: PAYMENT_STATUS.SCHEDULED, +}; + +export const URL_QUERY_PARAM_MAP = new Map([ + ["startDate", "startDate"], + ["paymentStatuses", "status"], + ["onlyFailedPayments", "onlyFailed"], + ["userHandle", "user"], + ["criteria", "by"], + ["order", "order"], + ["pageSize", "perPage"], + ["pageNumber", "page"], +]); + export const JOB_NAME_LOADING = "Loading..."; export const JOB_NAME_NONE = ""; export const JOB_NAME_ERROR = ""; diff --git a/src/constants/workPeriods/apiChallengePaymentStatus.js b/src/constants/workPeriods/apiChallengePaymentStatus.js new file mode 100644 index 0000000..09e235c --- /dev/null +++ b/src/constants/workPeriods/apiChallengePaymentStatus.js @@ -0,0 +1,5 @@ +export const CANCELLED = "cancelled"; +export const COMPLETED = "completed"; +export const FAILED = "failed"; +export const IN_PROGRESS = "in-progress"; +export const SCHEDULED = "scheduled"; diff --git a/src/constants/workPeriods/paymentStatus.js b/src/constants/workPeriods/paymentStatus.js index 007dfa0..57ccb3d 100644 --- a/src/constants/workPeriods/paymentStatus.js +++ b/src/constants/workPeriods/paymentStatus.js @@ -1,5 +1,9 @@ -export const PARTIALLY_COMPLETED = "PARTIALLY_COMPLETED"; +export const CANCELLED = "CANCELLED"; export const COMPLETED = "COMPLETED"; -export const PENDING = "PENDING"; +export const FAILED = "FAILED"; export const IN_PROGRESS = "IN_PROGRESS"; export const NO_DAYS = "NO_DAYS"; +export const PARTIALLY_COMPLETED = "PARTIALLY_COMPLETED"; +export const PENDING = "PENDING"; +export const SCHEDULED = "SCHEDULED"; +export const UNDEFINED = "UNDEFINED"; diff --git a/src/constants/workPeriods/sortBy.js b/src/constants/workPeriods/sortBy.js index cafb587..c305307 100644 --- a/src/constants/workPeriods/sortBy.js +++ b/src/constants/workPeriods/sortBy.js @@ -4,6 +4,6 @@ export const START_DATE = "START_DATE"; export const END_DATE = "END_DATE"; export const ALERT = "ALERT"; export const WEEKLY_RATE = "WEEKLY_RATE"; -export const PAYMENT_STATUS = "STATUS"; -export const PAYMENT_TOTAL = "TOTAL_PAYMENT"; +export const PAYMENT_STATUS = "PAYMENT_STATUS"; +export const PAYMENT_TOTAL = "PAYMENT_TOTAL"; export const WORKING_DAYS = "WORKING_DAYS"; diff --git a/src/root.component.jsx b/src/root.component.jsx index fe6e308..017dd42 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -5,7 +5,11 @@ import store from "store"; import { disableSidebarForRoute } from "@topcoder/micro-frontends-navbar-app"; import WorkPeriods from "routes/WorkPeriods"; import Freelancers from "routes/Freelancers"; -import { APP_BASE_PATH } from "./constants"; +import { + APP_BASE_PATH, + FREELANCERS_PATH, + WORK_PERIODS_PATH, +} from "./constants"; import "styles/global.scss"; export default function Root() { @@ -16,14 +20,9 @@ export default function Root() { return ( - - - + + + ); diff --git a/src/routes/WorkPeriods/components/PaymentError/index.jsx b/src/routes/WorkPeriods/components/PaymentError/index.jsx new file mode 100644 index 0000000..81557e8 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentError/index.jsx @@ -0,0 +1,66 @@ +import React, { useCallback, useRef, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Popup from "components/Popup"; +import PaymentErrorDetails from "../PaymentErrorDetails"; +import { useClickOutside } from "utils/hooks"; +import { negate } from "utils/misc"; +import styles from "./styles.module.scss"; + +/** + * Displays an error icon and error details popup. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {Object} [props.errorDetails] error details object + * @param {boolean} [props.isImportant] whether the error deemed important + * @param {'absolute'|'fixed'} [props.popupStrategy] popup positioning strategy + * @returns {JSX.Element} + */ +const PaymentError = ({ + className, + errorDetails, + isImportant = true, + popupStrategy = "absolute", +}) => { + const [isShowPopup, setIsShowPopup] = useState(false); + const [refElem, setRefElem] = useState(null); + const containerRef = useRef(null); + + const onIconClick = useCallback((event) => { + event.stopPropagation(); + setIsShowPopup(negate); + }, []); + + const onClickOutside = useCallback(() => { + setIsShowPopup(false); + }, []); + + useClickOutside(containerRef, onClickOutside, []); + + return ( +
+ + {isShowPopup && errorDetails && ( + + + + )} +
+ ); +}; + +PaymentError.propTypes = { + className: PT.string, + errorDetails: PT.object, + isImportant: PT.bool, + popupStrategy: PT.oneOf(["absolute", "fixed"]), +}; + +export default PaymentError; diff --git a/src/routes/WorkPeriods/components/PaymentError/styles.module.scss b/src/routes/WorkPeriods/components/PaymentError/styles.module.scss new file mode 100644 index 0000000..ff2dbb1 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentError/styles.module.scss @@ -0,0 +1,31 @@ +@import "styles/variables"; + +.container { + display: inline-block; + position: relative; +} + +.icon { + display: inline-block; + padding: 2px 0 0; + font-size: 12px; + width: 16px; + height: 16px; + border-radius: 8px; + line-height: 14px; + text-align: center; + background: $error-color; + color: #fff; + opacity: 0.3; + cursor: pointer; + + &.isImportant { + opacity: 1; + } + + &::before { + content: "!"; + display: inline; + font-weight: 700; + } +} diff --git a/src/routes/WorkPeriods/components/PaymentErrorDetails/index.jsx b/src/routes/WorkPeriods/components/PaymentErrorDetails/index.jsx new file mode 100644 index 0000000..f06fc2e --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentErrorDetails/index.jsx @@ -0,0 +1,66 @@ +import React from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import { formatChallengeUrl } from "utils/formatters"; +import styles from "./styles.module.scss"; + +/** + * Displays payment error details. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {Object} props.details error details + * @returns {JSX.Element} + */ +const PaymentErrorDetails = ({ className, details }) => { + const { challengeId, errorMessage, errorCode, retry, step } = details; + return ( +
+
+ Challenge: + {challengeId ? ( + + {challengeId} + + ) : ( + {""} + )} +
+
+ Error: + {errorMessage} +
+
+ + Code: + {errorCode} + + + Retry: + {retry} + + + Step: + {step} + +
+
+ ); +}; + +PaymentErrorDetails.propTypes = { + className: PT.string, + details: PT.shape({ + challengeId: PT.string, + errorCode: PT.number.isRequired, + errorMessage: PT.string.isRequired, + retry: PT.number.isRequired, + step: PT.string.isRequired, + }).isRequired, +}; + +export default PaymentErrorDetails; diff --git a/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss b/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss new file mode 100644 index 0000000..a1f4bf1 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss @@ -0,0 +1,35 @@ +@import "styles/mixins"; + +.container { + display: block; + max-width: 480px; + text-align: left; +} + +.row { + display: flex; + align-items: baseline; + white-space: nowrap; + + a { + color: #0d61bf; + } +} + +.cell { + margin-left: 20px; + white-space: nowrap; + + &:first-child { + margin-left: 0; + } +} + +.errorMessage { + white-space: normal; +} + +.label { + margin-right: 5px; + @include roboto-medium; +} diff --git a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss index 14d448a..1354d98 100644 --- a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss @@ -22,10 +22,12 @@ background: #efd047; } -.in_progress { +.in_progress, +.scheduled { background: #9d41c9; } +.cancelled, .no_days { background: #aaa; } @@ -34,6 +36,10 @@ background: #ef476f; } +.failed { + background: $error-color; +} + .undefined { padding: 0; @include roboto-regular; diff --git a/src/routes/WorkPeriods/components/PaymentTotal/index.jsx b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx new file mode 100644 index 0000000..8b2d1cb --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx @@ -0,0 +1,82 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import React, { useCallback, useRef, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Popup from "components/Popup"; +import PaymentsList from "../PaymentsList"; +import { useClickOutside } from "utils/hooks"; +import { currencyFormatter } from "utils/formatters"; +import { negate, stopPropagation } from "utils/misc"; +import styles from "./styles.module.scss"; + +/** + * Displays the total paid sum and a popup with payments' list. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {Array} [props.payments] an array with payments information + * @param {number} props.paymentTotal total paid sum + * @param {number} props.daysPaid number of paid days + * @param {'absolute'|'fixed'} [props.popupStrategy] popup positioning strategy + * @returns {JSX.Element} + */ +const PaymentTotal = ({ + className, + payments, + paymentTotal, + daysPaid, + popupStrategy = "absolute", +}) => { + const [isShowPopup, setIsShowPopup] = useState(false); + const [refElem, setRefElem] = useState(null); + const containerRef = useRef(null); + + const onWeeklyRateClick = useCallback(() => { + setIsShowPopup(negate); + }, []); + + const onClickOutside = useCallback(() => { + setIsShowPopup(false); + }, []); + + const hasPayments = !!payments && !!payments.length; + + useClickOutside(containerRef, onClickOutside, []); + + return ( +
+ + + {currencyFormatter.format(paymentTotal)} + +   + ({daysPaid}) + + {hasPayments && isShowPopup && ( + + + + )} +
+ ); +}; + +PaymentTotal.propTypes = { + className: PT.string, + payments: PT.array, + paymentTotal: PT.number.isRequired, + daysPaid: PT.number.isRequired, + popupStrategy: PT.oneOf(["absolute", "fixed"]), +}; + +export default PaymentTotal; diff --git a/src/routes/WorkPeriods/components/PaymentTotal/styles.module.scss b/src/routes/WorkPeriods/components/PaymentTotal/styles.module.scss new file mode 100644 index 0000000..4fe846e --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentTotal/styles.module.scss @@ -0,0 +1,20 @@ +@import "styles/variables"; + +.container { + position: relative; + display: inline-block; +} + +.paymentTotal { + display: inline-block; + line-height: 16px; + + &.hasPayments { + border-bottom: 1px dashed $text-color; + cursor: pointer; + } +} + +.daysPaid { + color: #aaa; +} diff --git a/src/routes/WorkPeriods/components/PaymentsList/index.jsx b/src/routes/WorkPeriods/components/PaymentsList/index.jsx new file mode 100644 index 0000000..7391317 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentsList/index.jsx @@ -0,0 +1,43 @@ +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 PaymentsList = ({ className, payments }) => ( +
+ + + + + + + + + + + + {payments.map((payment) => ( + + ))} + +
Challenge IDWeekly RateDaysAmountStatus
+
+); + +PaymentsList.propTypes = { + className: PT.string, + payments: PT.arrayOf( + PT.shape({ + id: PT.string.isRequired, + }) + ), +}; + +export default PaymentsList; diff --git a/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss new file mode 100644 index 0000000..a878a33 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss @@ -0,0 +1,41 @@ +@import "styles/mixins"; + +.container { + display: block; +} + +.title { + @include roboto-medium; + line-height: 20px; + white-space: nowrap; +} + +table.paymentsList { + margin-top: 5px; + + th { + @include roboto-bold; + padding: 10px 7px; + font-size: 12px; + line-height: 16px; + white-space: nowrap; + text-align: right; + background: #f4f4f4; + + &:first-child, + &:last-child { + text-align: left; + } + + &:first-child { + padding-left: 28px; + } + } + + tr { + td { + padding: 5px 7px; + white-space: nowrap; + } + } +} diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx index 1914614..939f998 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -2,11 +2,13 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ import React, { useCallback, useRef } from "react"; import PT from "prop-types"; -import cn from "classnames"; +import { currencyFormatter, formatChallengeUrl } from "utils/formatters"; +import { PAYMENT_STATUS } from "constants/workPeriods"; +import PaymentStatus from "../PaymentStatus"; import styles from "./styles.module.scss"; -import { formatChallengeUrl } from "utils/formatters"; +import PaymentError from "../PaymentError"; -const PaymentsListItem = ({ className, item }) => { +const PaymentsListItem = ({ item }) => { const inputRef = useRef(); const onCopyLinkClick = useCallback(() => { @@ -16,32 +18,59 @@ const PaymentsListItem = ({ className, item }) => { }, []); return ( -
- - - - - {item.id} - -
+ + +
+ + + + + {item.id} + +
+ + + {currencyFormatter.format(item.memberRate)} + + {item.days} + {currencyFormatter.format(item.amount)} + +
+ + {item.status === PAYMENT_STATUS.FAILED && ( + + )} +
+ + ); }; PaymentsListItem.propTypes = { - className: PT.string, item: PT.shape({ - id: PT.oneOfType([PT.string, PT.number]), + id: PT.oneOfType([PT.string, PT.number]).isRequired, + amount: PT.number.isRequired, challengeId: PT.oneOfType([PT.string, PT.number]), + days: PT.number.isRequired, + memberRate: PT.number.isRequired, + status: PT.string.isRequired, + statusDetails: PT.object, }), }; diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss index b71ea11..bee0edd 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss @@ -1,62 +1,76 @@ @import "styles/mixins"; -.container { - display: flex; - align-items: center; +.challengeId { + display: inline-block; input { - display: block; + display: inline-block; margin: 0; - border: none; + border: none !important; padding: 0; - max-width: 70px; + width: 70px; height: 22px; background: #fff; @include roboto-medium; line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; outline: none !important; + box-shadow: none !important; + pointer-events: none; color: #0d61bf; } } -.iconLink { - flex: 0 0 auto; +.iconLink, +.iconCopyLink, +.iconOpenLink { display: inline-block; - margin-right: 5px; + vertical-align: -2px; width: 16px; height: 16px; background-repeat: no-repeat; background-size: contain; background-position: center; +} + +.iconLink { + margin-right: 5px; 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"); } +.weeklyRate, +.days, +.amount { + text-align: right; +} + +.paymentStatus { + white-space: nowrap; +} + +.statusWithError { + display: flex; + align-items: baseline; +} + +.paymentError { + margin-left: 5px; +} + .hidden { display: none; } diff --git a/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx b/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx deleted file mode 100644 index 93e8324..0000000 --- a/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx +++ /dev/null @@ -1,36 +0,0 @@ -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 ( -
-
Challenges for Payments
-
- {payments.map((payment) => ( - - ))} -
-
- ); -}; - -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 deleted file mode 100644 index a29e703..0000000 --- a/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss +++ /dev/null @@ -1,20 +0,0 @@ -@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 index 687e5d5..e34978b 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -85,7 +85,7 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { )} > {periodsIsLoading ? ( - +
Loading...
) : ( diff --git a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx index 7d8974f..606555b 100644 --- a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx @@ -1,21 +1,27 @@ -import React, { useCallback } from "react"; +import React, { memo, useCallback } from "react"; 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 Button from "components/Button"; -import SearchHandleField from "components/SearchHandleField"; import CheckboxList from "components/CheckboxList"; +import SearchHandleField from "components/SearchHandleField"; +import SidebarSection from "components/SidebarSection"; +import Toggle from "components/Toggle"; import { PAYMENT_STATUS } from "constants/workPeriods"; import { getWorkPeriodsFilters } from "store/selectors/workPeriods"; import { resetWorkPeriodsFilters, setWorkPeriodsPaymentStatuses, setWorkPeriodsUserHandle, + toggleShowFailedPaymentsOnly, } from "store/actions/workPeriods"; -import { loadWorkPeriodsPage as loadWorkingPeriodsPage } from "store/thunks/workPeriods"; +import { + loadWorkPeriodsPage, + updateQueryFromState, +} from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; +import { preventDefault } from "utils/misc"; import styles from "./styles.module.scss"; /** @@ -29,11 +35,20 @@ import styles from "./styles.module.scss"; const PeriodFilters = ({ className }) => { const dispatch = useDispatch(); const filters = useSelector(getWorkPeriodsFilters); - const { paymentStatuses, userHandle } = filters; + const { onlyFailedPayments, paymentStatuses, userHandle } = filters; + + const onToggleFailedPayments = useCallback( + (on) => { + dispatch(toggleShowFailedPaymentsOnly(on)); + dispatch(updateQueryFromState()); + }, + [dispatch] + ); const onUserHandleChange = useCallback( (value) => { dispatch(setWorkPeriodsUserHandle(value)); + dispatch(updateQueryFromState()); }, [dispatch] ); @@ -41,18 +56,20 @@ const PeriodFilters = ({ className }) => { const onPaymentStatusesChange = useCallback( (statuses) => { dispatch(setWorkPeriodsPaymentStatuses(statuses)); + dispatch(updateQueryFromState()); }, [dispatch] ); const onClearFilter = useCallback(() => { dispatch(resetWorkPeriodsFilters()); + dispatch(updateQueryFromState()); }, [dispatch]); const loadWorkingPeriodsFirstPage = useCallback( debounce( () => { - dispatch(loadWorkingPeriodsPage(1)); + dispatch(loadWorkPeriodsPage); }, 300, { leading: false } @@ -64,7 +81,11 @@ const PeriodFilters = ({ className }) => { useUpdateEffect(loadWorkingPeriodsFirstPage, [filters]); return ( -
+
{ value={paymentStatuses} /> +
+ + +
- {HEAD_CELLS.map(({ id, label, disableSort }) => ( + {HEAD_CELLS.map(({ id, className, label, disableSort }) => ( -
+
{label} {!disableSort && ( { const onWeekSelect = useCallback( (date) => { dispatch(setWorkPeriodsDateRange(moment(date))); + dispatch(updateQueryFromState()); }, [dispatch] ); const onNextWeekSelect = useCallback(() => { dispatch(setWorkPeriodsDateRange(startDate.clone().add(1, "week"))); + dispatch(updateQueryFromState()); }, [startDate, dispatch]); const onPreviousWeekSelect = useCallback(() => { dispatch(setWorkPeriodsDateRange(startDate.clone().add(-1, "week"))); + dispatch(updateQueryFromState()); }, [startDate, dispatch]); return ( diff --git a/src/routes/WorkPeriods/components/Periods/index.jsx b/src/routes/WorkPeriods/components/Periods/index.jsx index 3f971da..4efd3f7 100644 --- a/src/routes/WorkPeriods/components/Periods/index.jsx +++ b/src/routes/WorkPeriods/components/Periods/index.jsx @@ -1,5 +1,6 @@ import React, { useEffect } from "react"; import { useSelector, useDispatch } from "react-redux"; +import { globalHistory } from "@reach/router"; import ContentMessage from "components/ContentMessage"; import PeriodList from "../PeriodList"; import { @@ -8,8 +9,11 @@ import { getWorkPeriodsPagination, getWorkPeriodsSorting, } from "store/selectors/workPeriods"; -import { loadWorkPeriodsPage } from "store/thunks/workPeriods"; -import { useUpdateEffect } from "utils/hooks"; +import { updateStateFromQuery } from "store/actions/workPeriods"; +import { + loadWorkPeriodsPage, + updateQueryFromState, +} from "store/thunks/workPeriods"; /** * Displays working periods' list or a "Loading..." message or an error message. @@ -23,16 +27,19 @@ const Periods = () => { const isLoading = useSelector(getWorkPeriodsIsLoading); const dispatch = useDispatch(); - // Load working periods' first page once when page loads and then - // only if page size or sorting changes. + // Load working periods' page only if page number, page size or sorting changes. useEffect(() => { - dispatch(loadWorkPeriodsPage(1)); - }, [dispatch, pagination.pageSize, sorting]); + dispatch(loadWorkPeriodsPage); + }, [dispatch, pagination.pageNumber, pagination.pageSize, sorting]); - // Load working periods' new page if page number changes. - useUpdateEffect(() => { - dispatch(loadWorkPeriodsPage()); - }, [dispatch, pagination.pageNumber]); + useEffect(() => { + dispatch(updateQueryFromState(true)); + return globalHistory.listen(({ action, location }) => { + if (action === "POP") { + dispatch(updateStateFromQuery(location.search)); + } + }); + }, [dispatch]); return ( <> diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx index fd61966..f38e4dc 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx @@ -5,8 +5,9 @@ import cn from "classnames"; import debounce from "lodash/debounce"; import moment from "moment"; import IntegerField from "components/IntegerField"; +import PaymentError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; -import PeriodsHistoryPaymentTotal from "../PeriodsHistoryPaymentTotal"; +import PaymentTotal from "../PaymentTotal"; import { PAYMENT_STATUS } from "constants/workPeriods"; import { setDetailsWorkingDays } from "store/actions/workPeriods"; import { updateWorkPeriodWorkingDays } from "store/thunks/workPeriods"; @@ -63,9 +64,16 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { {dateLabel} - + )} + @@ -74,7 +82,7 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { - {data.paymentStatus === PAYMENT_STATUS.PAID ? ( + {data.paymentStatus === PAYMENT_STATUS.COMPLETED ? ( `${daysWorked} ${daysWorked === 1 ? "Day" : "Days"}` ) : ( { - 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 ( -
-
- - {currencyFormatter.format(paymentTotal)} - - ({daysPaid}) -
- {hasPayments && isShowPopup && ( -
- -
-
- )} -
- ); -}; - -PeriodsHistoryPaymentTotal.propTypes = { - className: PT.string, - payments: PT.array, - paymentTotal: PT.number.isRequired, - daysPaid: PT.number.isRequired, -}; - -function negate(value) { - return !value; -} - -export default PeriodsHistoryPaymentTotal; diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/styles.module.scss deleted file mode 100644 index 60b6009..0000000 --- a/src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/styles.module.scss +++ /dev/null @@ -1,29 +0,0 @@ -.container { - position: relative; - - .dropdown-arrow { - display: none; - } -} - -.paymentTotal { - white-space: nowrap; -} - -.paymentTotalSum { - display: inline-block; - width: 70px; - text-align: right; -} - -.daysPaid { - color: #aaa; -} - -.hasPayments { - cursor: pointer; -} - -.popup { - z-index: 1; -} diff --git a/src/routes/WorkPeriods/components/PeriodsPagination/index.jsx b/src/routes/WorkPeriods/components/PeriodsPagination/index.jsx index cec8594..0ab97fd 100644 --- a/src/routes/WorkPeriods/components/PeriodsPagination/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsPagination/index.jsx @@ -8,6 +8,7 @@ import { setWorkPeriodsPageNumber, setWorkPeriodsPageSize, } from "store/actions/workPeriods"; +import { updateQueryFromState } from "store/thunks/workPeriods"; import styles from "./styles.module.scss"; /** @@ -25,6 +26,7 @@ const PeriodsPagination = ({ className, id }) => { const onPageNumberClick = useCallback( (pageNumber) => { dispatch(setWorkPeriodsPageNumber(+pageNumber)); + dispatch(updateQueryFromState()); }, [dispatch] ); @@ -32,6 +34,7 @@ const PeriodsPagination = ({ className, id }) => { const onPageSizeChange = useCallback( (pageSize) => { dispatch(setWorkPeriodsPageSize(+pageSize)); + dispatch(updateQueryFromState()); }, [dispatch] ); diff --git a/src/services/workPeriods.js b/src/services/workPeriods.js index c906236..88e2385 100644 --- a/src/services/workPeriods.js +++ b/src/services/workPeriods.js @@ -4,7 +4,7 @@ import { JOBS_API_URL, PAYMENTS_API_URL, PROJECTS_API_URL, - QUERY_PARAM_NAMES, + API_QUERY_PARAM_NAMES, WORK_PERIODS_API_URL, } from "constants/workPeriods"; import { buildRequestQuery, extractResponseData } from "utils/misc"; @@ -98,9 +98,12 @@ export const fetchWorkPeriods = (rbId, source) => { export const fetchResourceBookings = (params) => { const source = CancelToken.source(); return [ - axios.get(`${RB_API_URL}?${buildRequestQuery(params, QUERY_PARAM_NAMES)}`, { - cancelToken: source.token, - }), + axios.get( + `${RB_API_URL}?${buildRequestQuery(params, API_QUERY_PARAM_NAMES)}`, + { + cancelToken: source.token, + } + ), source, ]; }; diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 85eb490..de5fdf2 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -19,17 +19,19 @@ export const WP_SET_DETAILS_HIDE_PAST_PERIODS = "WP_SET_DETAILS_HIDE_PAST_PERIODS"; export const WP_SET_PAGE_NUMBER = "WP_SET_PAGE_NUMBER"; export const WP_SET_PAGE_SIZE = "WP_SET_PAGE_SIZE"; -export const WP_SET_DATA_PENDING = "WP_SET_DATA_PENDING"; -export const WP_SET_DATA_SUCCESS = "WP_SET_DATA_SUCCESS"; -export const WP_SET_DATA_ERROR = "WP_SET_DATA_ERROR"; export const WP_SET_DATE_RANGE = "WP_SET_DATE_RANGE"; +export const WP_SET_PAYMENT_STATUSES = "WP_SET_PAYMENT_STATUSES"; +export const WP_SET_PERIOD_DATA_PENDING = "WP_SET_PERIOD_DATA_PENDING"; +export const WP_SET_PERIOD_DATA_SUCCESS = "WP_SET_PERIOD_DATA_SUCCESS"; +export const WP_SET_PERIOD_DATA_ERROR = "WP_SET_PERIOD_DATA_ERROR"; export const WP_SET_SORT_BY = "WP_SET_SORT_BY"; export const WP_SET_SORT_ORDER = "WP_SET_SORT_ORDER"; export const WP_SET_SORTING = "WP_SET_SORTING"; -export const WP_SET_PAYMENT_STATUSES = "WP_SET_PAYMENT_STATUSES"; export const WP_SET_USER_HANDLE = "WP_SET_USER_HANDLE"; export const WP_SET_WORKING_DAYS = "WP_SET_WORKING_DAYS"; +export const WP_TOGGLE_ONLY_FAILED_PAYMENTS = "WP_TOGGLE_ONLY_FAILED_PAYMENTS"; export const WP_TOGGLE_PERIOD = "WP_TOGGLE_PERIOD"; export const WP_TOGGLE_PERIODS_ALL = "WP_TOGGLE_PERIODS_ALL"; export const WP_TOGGLE_PERIODS_VISIBLE = "WP_TOGGLE_PERIODS_VISIBLE"; export const WP_TOGGLE_PROCESSING_PAYMENTS = "WP_TOGGLE_PROCESSING_PAYMENTS"; +export const WP_UPDATE_STATE_FROM_QUERY = "WP_UPDATE_STATE_FROM_QUERY"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index 592cd1c..e1e72a4 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -4,28 +4,28 @@ import * as ACTION_TYPE from "store/actionTypes/workPeriods"; let nextErrorId = 1; /** - * Creates an action denoting the start of loading specific challenge page. + * Creates an action denoting the start of loading specific working period page. * * @param {Object} cancelSource object that can be used to cancel network request - * @param {number} pageNumber the requested challenge page number * @returns {Object} */ -export const loadWorkPeriodsPagePending = (cancelSource, pageNumber) => ({ +export const loadWorkPeriodsPagePending = (cancelSource) => ({ type: ACTION_TYPE.WP_LOAD_PAGE_PENDING, - payload: { cancelSource, pageNumber }, + payload: cancelSource, }); /** * 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 - * @param {number} pageCount total number of pages + * @param {Object} payload action payload + * @param {Array} payload.periods array of working period objects + * @param {number} payload.totalCount total number of periods for current filters' state + * @param {number} payload.pageCount total number of pages * @returns {Object} */ -export const loadWorkPeriodsPageSuccess = (periods, totalCount, pageCount) => ({ +export const loadWorkPeriodsPageSuccess = (payload) => ({ type: ACTION_TYPE.WP_LOAD_PAGE_SUCCESS, - payload: { periods, totalCount, pageCount }, + payload, }); /** @@ -326,20 +326,25 @@ export const setWorkPeriodWorkingDays = (periodId, daysWorked) => ({ * @returns {Object} */ export const setWorkPeriodDataPending = (periodId, cancelSource) => ({ - type: ACTION_TYPE.WP_SET_DATA_PENDING, + type: ACTION_TYPE.WP_SET_PERIOD_DATA_PENDING, payload: { periodId, cancelSource }, }); export const setWorkPeriodDataSuccess = (periodId, data) => ({ - type: ACTION_TYPE.WP_SET_DATA_SUCCESS, + type: ACTION_TYPE.WP_SET_PERIOD_DATA_SUCCESS, payload: { periodId, data }, }); export const setWorkPeriodDataError = (periodId, message) => ({ - type: ACTION_TYPE.WP_SET_DATA_ERROR, + type: ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR, payload: { periodId, message }, }); +export const toggleShowFailedPaymentsOnly = (on = null) => ({ + type: ACTION_TYPE.WP_TOGGLE_ONLY_FAILED_PAYMENTS, + payload: on, +}); + /** * Creates an action to toggle certain working period by its id. * @@ -383,3 +388,15 @@ export const toggleWorkPeriodsProcessingPeyments = (on = null) => ({ type: ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS, payload: on, }); + +/** + * Creates an action denoting an update of working periods state slice using + * the provided query. + * + * @param {string} query URL search query + * @returns {Object} + */ +export const updateStateFromQuery = (query) => ({ + type: ACTION_TYPE.WP_UPDATE_STATE_FROM_QUERY, + payload: query, +}); diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 7465c3a..e34ddeb 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -1,13 +1,17 @@ import moment from "moment"; import * as ACTION_TYPE from "store/actionTypes/workPeriods"; import { - SORT_BY_DEFAULT, - SORT_ORDER_DEFAULT, - JOB_NAME_ERROR, BILLING_ACCOUNTS_NONE, - JOB_NAME_LOADING, BILLING_ACCOUNTS_LOADING, BILLING_ACCOUNTS_ERROR, + JOB_NAME_ERROR, + JOB_NAME_LOADING, + PAYMENT_STATUS, + SORT_BY, + SORT_BY_DEFAULT, + SORT_ORDER, + SORT_ORDER_DEFAULT, + URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; import { filterPeriodsByStartDate, @@ -18,6 +22,8 @@ import { createAssignedBillingAccountOption } from "utils/workPeriods"; const cancelSourceDummy = { cancel: () => {} }; +const PAGE_SIZES = [10, 20, 50, 100]; + const initPagination = () => ({ totalCount: 0, pageCount: 0, @@ -27,6 +33,7 @@ const initPagination = () => ({ const initFilters = () => ({ dateRange: getWeekByDate(moment()), + onlyFailedPayments: false, paymentStatuses: {}, // all disabled by default userHandle: "", }); @@ -50,30 +57,30 @@ const initPeriodDetails = ( billingAccountsError: null, billingAccountsIsDisabled: true, billingAccountsIsLoading: true, + hidePastPeriods: false, periods: [], periodsVisible: [], periodsIsLoading: true, - hidePastPeriods: false, }); -const initialState = { - error: null, +const initialState = updateStateFromQuery(window.location.search, { cancelSource: cancelSourceDummy, + error: null, + filters: initFilters(), + isProcessingPayments: false, + isSelectedPeriodsAll: false, + isSelectedPeriodsVisible: false, + pagination: initPagination(), periods: [], periodsData: [{}], periodsDetails: {}, periodsFailed: {}, periodsSelected: {}, - isSelectedPeriodsAll: false, - isSelectedPeriodsVisible: false, - isProcessingPayments: false, - pagination: initPagination(), sorting: { criteria: SORT_BY_DEFAULT, order: SORT_ORDER_DEFAULT, }, - filters: initFilters(), -}; +}); const reducer = (state = initialState, action) => { if (action.type in actionHandlers) { @@ -83,24 +90,17 @@ const reducer = (state = initialState, action) => { }; const actionHandlers = { - [ACTION_TYPE.WP_LOAD_PAGE_PENDING]: ( - state, - { cancelSource, pageNumber } - ) => ({ + [ACTION_TYPE.WP_LOAD_PAGE_PENDING]: (state, cancelSource) => ({ ...state, cancelSource, error: null, + isSelectedPeriodsAll: false, + isSelectedPeriodsVisible: false, periods: [], periodsData: [{}], periodsDetails: {}, periodsFailed: {}, periodsSelected: {}, - isSelectedPeriodsAll: false, - isSelectedPeriodsVisible: false, - pagination: - pageNumber === state.pagination.pageNumber - ? state.pagination - : { ...state.pagination, pageNumber }, }), [ACTION_TYPE.WP_LOAD_PAGE_SUCCESS]: ( state, @@ -122,9 +122,9 @@ const actionHandlers = { ...state, cancelSource: null, error: null, + pagination, periods, periodsData: [periodsData], - pagination, }; }, [ACTION_TYPE.WP_LOAD_PAGE_ERROR]: (state, error) => { @@ -404,6 +404,10 @@ const actionHandlers = { [ACTION_TYPE.WP_RESET_FILTERS]: (state) => ({ ...state, filters: initFilters(), + pagination: { + ...state.pagination, + pageNumber: 1, + }, }), [ACTION_TYPE.WP_SET_DATE_RANGE]: (state, date) => { const oldRange = state.filters.dateRange; @@ -417,6 +421,10 @@ const actionHandlers = { ...state.filters, dateRange: range, }, + pagination: { + ...state.pagination, + pageNumber: 1, + }, }; }, [ACTION_TYPE.WP_SELECT_PERIODS]: (state, periods) => { @@ -462,10 +470,14 @@ const actionHandlers = { pagination: pageSize === state.pagination.pageSize ? state.pagination - : { ...state.pagination, pageSize }, + : { ...state.pagination, pageSize, pageNumber: 1 }, }), [ACTION_TYPE.WP_SET_SORT_BY]: (state, criteria) => ({ ...state, + pagination: { + ...state.pagination, + pageNumber: 1, + }, sorting: { ...state.sorting, criteria, @@ -481,6 +493,10 @@ const actionHandlers = { } return { ...state, + pagination: { + ...state.pagination, + pageNumber: 1, + }, sorting: { criteria: sortBy, order: sortOrder, @@ -496,6 +512,10 @@ const actionHandlers = { paymentStatuses ), }, + pagination: { + ...state.pagination, + pageNumber: 1, + }, }), [ACTION_TYPE.WP_SET_USER_HANDLE]: (state, userHandle) => { if (userHandle === state.filters.userHandle) { @@ -507,9 +527,16 @@ const actionHandlers = { ...state.filters, userHandle, }, + pagination: { + ...state.pagination, + pageNumber: 1, + }, }; }, - [ACTION_TYPE.WP_SET_DATA_PENDING]: (state, { periodId, cancelSource }) => { + [ACTION_TYPE.WP_SET_PERIOD_DATA_PENDING]: ( + state, + { periodId, cancelSource } + ) => { const periodsData = state.periodsData[0]; const periodData = periodsData[periodId]; if (!periodData) { @@ -524,7 +551,7 @@ const actionHandlers = { periodsData: [periodsData], }; }, - [ACTION_TYPE.WP_SET_DATA_SUCCESS]: (state, { periodId, data }) => { + [ACTION_TYPE.WP_SET_PERIOD_DATA_SUCCESS]: (state, { periodId, data }) => { const periodsData = state.periodsData[0]; const periodData = periodsData[periodId]; if (!periodData) { @@ -540,7 +567,7 @@ const actionHandlers = { periodsData: [periodsData], }; }, - [ACTION_TYPE.WP_SET_DATA_ERROR]: (state, { periodId }) => { + [ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR]: (state, { periodId }) => { const periodsData = state.periodsData[0]; const periodData = periodsData[periodId]; if (!periodData) { @@ -571,6 +598,24 @@ const actionHandlers = { periodsData: [periodsData], }; }, + [ACTION_TYPE.WP_TOGGLE_ONLY_FAILED_PAYMENTS]: (state, on) => { + const filters = state.filters; + on = on === null ? !filters.onlyFailedPayments : on; + if (on === filters.onlyFailedPayments) { + return state; + } + return { + ...state, + filters: { + ...filters, + onlyFailedPayments: on, + }, + pagination: { + ...state.pagination, + pageNumber: 1, + }, + }; + }, [ACTION_TYPE.WP_TOGGLE_PERIOD]: (state, periodId) => { let isSelectedPeriodsAll = state.isSelectedPeriodsAll; let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; @@ -647,6 +692,116 @@ const actionHandlers = { isProcessingPayments, }; }, + [ACTION_TYPE.WP_UPDATE_STATE_FROM_QUERY]: (state, query) => + updateStateFromQuery(query, state), }; +/** + * Updates state from current URL's query. + * + * @param {string} queryStr query string + * @param {Object} state working periods' state slice + * @returns {Object} initial state + */ +function updateStateFromQuery(queryStr, state) { + const params = {}; + const query = new URLSearchParams(queryStr); + for (let [stateKey, queryKey] of URL_QUERY_PARAM_MAP) { + let value = query.get(queryKey); + if (value) { + params[stateKey] = value; + } + } + let updateFilters = false; + let updatePagination = false; + let updateSorting = false; + const { filters, pagination, sorting } = state; + const { dateRange } = filters; + // checking start date + let range = getWeekByDate(moment(params.startDate)); + if (!range[0].isSame(dateRange[0])) { + filters.dateRange = range; + updateFilters = true; + } + // checking payment statuses + let hasSameStatuses = true; + const filtersPaymentStatuses = filters.paymentStatuses; + const queryPaymentStatuses = {}; + const paymentStatusesStr = params.paymentStatuses; + if (paymentStatusesStr) { + for (let status of paymentStatusesStr.split(",")) { + status = status.toUpperCase(); + if (status in PAYMENT_STATUS) { + queryPaymentStatuses[status] = true; + if (!filtersPaymentStatuses[status]) { + hasSameStatuses = false; + } + } + } + } + for (let status in filtersPaymentStatuses) { + if (!queryPaymentStatuses[status]) { + hasSameStatuses = false; + break; + } + } + if (!hasSameStatuses) { + filters.paymentStatuses = queryPaymentStatuses; + updateFilters = true; + } + // chacking only failed payments flag + const onlyFailedFlag = params.onlyFailedPayments?.slice(0, 1); + const onlyFailedPayments = onlyFailedFlag === "y"; + if (onlyFailedPayments !== filters.onlyFailedPayments) { + filters.onlyFailedPayments = onlyFailedPayments; + updateFilters = true; + } + // checking user handle + const userHandle = params.userHandle?.slice(0, 256) || ""; + if (userHandle !== filters.userHandle) { + filters.userHandle = userHandle; + updateFilters = true; + } + // checking sorting criteria + let criteria = params.criteria?.toUpperCase(); + criteria = criteria in SORT_BY ? criteria : SORT_BY_DEFAULT; + if (criteria !== sorting.criteria) { + sorting.criteria = criteria; + updateSorting = true; + } + // checking sorting order + let order = params.order; + order = + order && order.toUpperCase() in SORT_ORDER ? order : SORT_ORDER_DEFAULT; + if (order !== sorting.order) { + sorting.order = order; + updateSorting = true; + } + // checking page number + const pageNumber = +params.pageNumber; + if (pageNumber && pageNumber !== pagination.pageNumber) { + pagination.pageNumber = pageNumber; + updatePagination = true; + } + // checking page size + const pageSize = +params.pageSize; + if (PAGE_SIZES.includes(pageSize) && pageSize !== pagination.pageSize) { + pagination.pageSize = pageSize; + updatePagination = true; + } + if (updateFilters || updatePagination || updateSorting) { + state = { ...state }; + if (updateFilters) { + state.filters = { ...filters }; + } + if (updatePagination) { + state.pagination = { ...pagination }; + } + if (updateSorting) { + state.sorting = { ...sorting }; + } + } + return state; +} + export default reducer; diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js index 060d93f..9a826e7 100644 --- a/src/store/selectors/workPeriods.js +++ b/src/store/selectors/workPeriods.js @@ -50,6 +50,9 @@ export const getWorkPeriodsSelected = (state) => */ export const getWorkPeriodsFilters = (state) => state.workPeriods.filters; +export const getWorkPeriodsPaymentStatuses = (state) => + state.workPeriods.filters.paymentStatuses; + export const getWorkPeriodsDateRange = (state) => state.workPeriods.filters.dateRange; @@ -59,9 +62,17 @@ export const getWorkPeriodsSorting = (state) => state.workPeriods.sorting; export const getWorkPeriodsPagination = (state) => state.workPeriods.pagination; +export const getWorkPeriodsPageNumber = (state) => + state.workPeriods.pagination.pageNumber; + export const getWorkPeriodsPageSize = (state) => state.workPeriods.pagination.pageSize; +export const getWorkPeriodsUrlQuery = (state) => state.workPeriods.query; + +export const getWorkPeriodsIsQueryFromState = (state) => + state.workPeriods.isQueryFromState; + export const getWorkPeriodsCount = (state) => state.workPeriods.periods.length; export const getWorkPeriodsData = (state) => state.workPeriods.periodsData; diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index 785f807..a406918 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -1,4 +1,5 @@ import axios from "axios"; +import { navigate } from "@reach/router"; import * as actions from "store/actions/workPeriods"; import * as selectors from "store/selectors/workPeriods"; import * as services from "services/workPeriods"; @@ -7,8 +8,9 @@ import { API_SORT_BY, DATE_FORMAT_API, PAYMENT_STATUS_MAP, - FIELDS_QUERY, + API_FIELDS_QUERY, JOB_NAME_NONE, + API_CHALLENGE_PAYMENT_STATUS, } from "constants/workPeriods"; import { extractJobName, @@ -17,6 +19,7 @@ import { replaceItems, } from "utils/misc"; import { + makeUrlQuery, normalizeBillingAccounts, normalizeDetailsPeriodItems, normalizePeriodData, @@ -29,7 +32,7 @@ import { makeToastPaymentsWarning, makeToastPaymentsError, } from "routes/WorkPeriods/utils/toasts"; -import { RESOURCE_BOOKING_STATUS } from "constants/index.js"; +import { RESOURCE_BOOKING_STATUS, WORK_PERIODS_PATH } from "constants/index.js"; /** * Thunk that loads the specified working periods' page. If page number is not @@ -37,65 +40,84 @@ import { RESOURCE_BOOKING_STATUS } from "constants/index.js"; * working period filters are loaded from the current state to construct * a request query. * - * @param {number} [pageNumber] page number to load - * @returns {function} + * @returns {Promise} */ -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; +export const loadWorkPeriodsPage = 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 sortOrder = sorting.order; - const sortBy = SORT_BY_MAP[sorting.criteria] || API_SORT_BY.USER_HANDLE; + const { onlyFailedPayments, userHandle } = filters; + const [startDate] = filters.dateRange; + const paymentStatuses = replaceItems( + Object.keys(filters.paymentStatuses), + PAYMENT_STATUS_MAP + ); - 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: API_FIELDS_QUERY, + page: pagination.pageNumber, + perPage: pagination.pageSize, + sortBy, + sortOrder, + // we only want to show Resource Bookings with status "placed" + status: RESOURCE_BOOKING_STATUS.PLACED, + ["workPeriods.userHandle"]: userHandle, + ["workPeriods.startDate"]: startDate.format(DATE_FORMAT_API), + ["workPeriods.paymentStatus"]: paymentStatuses, + ["workPeriods.payments.status"]: onlyFailedPayments + ? API_CHALLENGE_PAYMENT_STATUS.FAILED + : null, + }); + dispatch(actions.loadWorkPeriodsPagePending(cancelSource)); + let totalCount, periods, pageCount; + try { + const response = await promise; + ({ totalCount, 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, + }) + ); +}; - // 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"]: 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; +/** + * Updates URL from current state. + * + * @param {boolean} replace whether to push or replace the history state + * @returns {function} + */ +export const updateQueryFromState = + (replace = false) => + (dispatch, getState) => { + const query = makeUrlQuery(selectors.getWorkPeriodsStateSlice(getState())); + if (query !== window.location.search.slice(1)) { + setTimeout(() => { + navigate(`${WORK_PERIODS_PATH}?${query}`, { replace }); + }, 100); // if executed synchronously navigate() causes a noticable lag } - dispatch( - actions.loadWorkPeriodsPageSuccess(periods, totalCount, pageCount) - ); }; /** diff --git a/src/styles/mixins/_screenSizes.scss b/src/styles/mixins/_screenSizes.scss index 52f0263..2adaeda 100644 --- a/src/styles/mixins/_screenSizes.scss +++ b/src/styles/mixins/_screenSizes.scss @@ -1,4 +1,4 @@ -@import 'variables/screenSizes'; +@import "variables/screenSizes"; // There's no need for phone() mixin for phone and larger screens to exist // because styles for these screens are simply added without media queries. diff --git a/src/styles/variables.scss b/src/styles/variables.scss index f3c2eda..e3a1501 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -2,3 +2,4 @@ @import "variables/layout"; @import "variables/colors"; @import "variables/forms"; +@import "variables/popup"; diff --git a/src/styles/variables/_colors.scss b/src/styles/variables/_colors.scss index 77b0bd3..5e0a4c6 100644 --- a/src/styles/variables/_colors.scss +++ b/src/styles/variables/_colors.scss @@ -4,6 +4,9 @@ $primary-light-color: #0ab88a; $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 + +$error-color: #e90c5a; + $text-color: #2a2a2a; $page-bg-color: #f4f5f6; diff --git a/src/styles/variables/_popup.scss b/src/styles/variables/_popup.scss new file mode 100644 index 0000000..c2c85f8 --- /dev/null +++ b/src/styles/variables/_popup.scss @@ -0,0 +1,3 @@ +$popover-padding: 18px 25px; +$popover-box-shadow: 0 5px 35px 5px rgba(21, 21, 22, 0.1), + 0 10px 14px 0 rgba(21, 21, 22, 0.3); diff --git a/src/styles/variables/_screenSizes.scss b/src/styles/variables/_screenSizes.scss index b1b0794..57f8881 100644 --- a/src/styles/variables/_screenSizes.scss +++ b/src/styles/variables/_screenSizes.scss @@ -2,9 +2,9 @@ $screen-codes: phone tablet desktop desktop-lg; // This map defines minimum screen widths for different devices. $screen-sizes: ( - 'phone': 320px, - 'tablet': 768px, - 'desktop': 1280px, + "phone": 320px, + "tablet": 768px, + "desktop": 1280px, ); // Media queries' mixins will use minimum screen widths from this map. @@ -20,7 +20,7 @@ $screen-min-px: (); $screen-min-px: map-merge( $screen-min-px, ( - 'desktop-lg': 1920px, + "desktop-lg": 1920px, ) ); diff --git a/src/utils/formatters.js b/src/utils/formatters.js index d720727..d3c0a17 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -12,7 +12,7 @@ const rxWhitespace = /\s+/; /** * Creates a challenge URL using challenge id. * - * @param {number} challengeId challenge id + * @param {string} challengeId challenge id * @returns {string} */ export function formatChallengeUrl(challengeId) { diff --git a/src/utils/hooks.js b/src/utils/hooks.js index 717943d..ccb7b6b 100644 --- a/src/utils/hooks.js +++ b/src/utils/hooks.js @@ -17,14 +17,12 @@ export const useClickOutside = (ref, listener, deps) => { listener(); } }; - document.addEventListener("mousedown", onClick); - document.addEventListener("touchstart", onClick); + document.addEventListener("click", onClick); return () => { - document.removeEventListener("touchstart", onClick); - document.removeEventListener("mousedown", onClick); + document.removeEventListener("click", onClick); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); + }, [listener, ...deps]); }; /** diff --git a/src/utils/misc.js b/src/utils/misc.js index 6e30d8a..0811244 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -67,6 +67,10 @@ export function replaceItems(array, map) { return result; } +export function preventDefault(event) { + event.preventDefault(); +} + /** * Stops event propagation. * @@ -144,4 +148,6 @@ export const extractResponseData = (response) => response.data; export const increment = (value) => value + 1; +export const negate = (value) => !value; + export const noop = () => {}; diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 1704540..ec97f28 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -1,5 +1,44 @@ import moment from "moment"; -import { API_PAYMENT_STATUS_MAP, DATE_FORMAT_UI } from "constants/workPeriods"; +import { + API_CHALLENGE_PAYMENT_STATUS_MAP, + API_PAYMENT_STATUS_MAP, + DATE_FORMAT_API, + DATE_FORMAT_UI, + PAYMENT_STATUS, + URL_QUERY_PARAM_MAP, +} from "constants/workPeriods"; + +/** + * Creates a URL search query from current state. + * + * @param {Object} state working periods' newly created state slice + * @returns {Object} + */ +export function makeUrlQuery(state) { + const { filters, pagination, sorting } = state; + const { dateRange, onlyFailedPayments, paymentStatuses, userHandle } = + filters; + const { pageNumber, pageSize } = pagination; + const { criteria, order } = sorting; + const params = { + startDate: dateRange[0].format(DATE_FORMAT_API), + paymentStatuses: Object.keys(paymentStatuses).join(",").toLowerCase(), + onlyFailedPayments: onlyFailedPayments ? "y" : "", + userHandle: encodeURIComponent(userHandle), + criteria: criteria.toLowerCase(), + order, + pageNumber, + pageSize, + }; + const queryParams = []; + for (let [stateKey, queryKey] of URL_QUERY_PARAM_MAP) { + let value = params[stateKey]; + if (value) { + queryParams.push(`${queryKey}=${value}`); + } + } + return queryParams.join("&"); +} export function normalizePeriodItems(items) { const empty = {}; @@ -26,6 +65,21 @@ export function normalizePeriodItems(items) { return periods; } +export function normalizeDetailsPeriodItems(items) { + const periods = []; + for (let item of items) { + periods.push({ + id: item.id, + startDate: item.startDate ? moment(item.startDate).valueOf() : 0, + endDate: item.endDate ? moment(item.endDate).valueOf() : 0, + weeklyRate: item.memberRate, + data: normalizePeriodData(item), + }); + } + periods.sort(sortByStartDate); + return periods; +} + /** * Normalizes specific working period data (daysWorked, daysPaid, * paymentStatus, paymentTotal). @@ -33,17 +87,37 @@ export function normalizePeriodItems(items) { * @param {Object} period * @param {number} period.daysWorked * @param {number} period.daysPaid + * @param {Array} [period.payments] * @param {string} period.paymentStatus * @param {number} period.paymentTotal * @returns {Object} */ export function normalizePeriodData(period) { - return { + const data = { daysWorked: period.daysWorked === null ? 5 : +period.daysWorked || 0, daysPaid: +period.daysPaid || 0, paymentStatus: normalizePaymentStatus(period.paymentStatus), paymentTotal: +period.paymentTotal || 0, }; + let payments = period.payments; + if (payments) { + let lastFailedPayment = null; + for (let payment of payments) { + payment.status = + API_CHALLENGE_PAYMENT_STATUS_MAP[payment.status] || + PAYMENT_STATUS.UNDEFINED; + if (payment.status === PAYMENT_STATUS.FAILED) { + lastFailedPayment = payment; + } + } + data.paymentErrorLast = lastFailedPayment?.statusDetails; + data.payments = payments; + } + return data; +} + +export function normalizePaymentStatus(paymentStatus) { + return API_PAYMENT_STATUS_MAP[paymentStatus]; } /** @@ -78,26 +152,6 @@ export function createAssignedBillingAccountOption(accountId) { return { value: accountId, label: ` (${accountId})` }; } -export function normalizeDetailsPeriodItems(items) { - const periods = []; - for (let item of items) { - periods.push({ - id: item.id, - startDate: item.startDate ? moment(item.startDate).valueOf() : 0, - endDate: item.endDate ? moment(item.endDate).valueOf() : 0, - payments: item.payments || [], - weeklyRate: item.memberRate, - data: normalizePeriodData(item), - }); - } - periods.sort(sortByStartDate); - return periods; -} - -export function normalizePaymentStatus(paymentStatus) { - return API_PAYMENT_STATUS_MAP[paymentStatus]; -} - export function sortByStartDate(itemA, itemB) { return itemA.startDate - itemB.startDate; }