diff --git a/src/components/Popup/index.jsx b/src/components/Popup/index.jsx new file mode 100644 index 0000000..3186f4c --- /dev/null +++ b/src/components/Popup/index.jsx @@ -0,0 +1,38 @@ +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"; + +const Popup = ({ children, className, referenceElement }) => { + 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 } }, + ], + }); + + return ( +
+ {children} +
+
+ ); +}; + +Popup.propTypes = { + children: PT.node, + className: PT.string, + referenceElement: PT.object.isRequired, +}; + +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/constants/workPeriods.js b/src/constants/workPeriods.js index 3ffefaa..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`; @@ -37,6 +45,13 @@ export const API_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. @@ -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 = { @@ -91,16 +110,17 @@ export const API_PAYMENT_STATUS_MAP = (function () { })(); export const API_CHALLENGE_PAYMENT_STATUS_MAP = { - cancelled: PAYMENT_STATUS.CANCELLED, - completed: PAYMENT_STATUS.COMPLETED, - failed: PAYMENT_STATUS.FAILED, - "in-progress": PAYMENT_STATUS.IN_PROGRESS, - scheduled: PAYMENT_STATUS.SCHEDULED, + [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"], 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 e16c899..57ccb3d 100644 --- a/src/constants/workPeriods/paymentStatus.js +++ b/src/constants/workPeriods/paymentStatus.js @@ -1,9 +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 FAILED = "FAILED"; -export const CANCELLED = "CANCELLED"; 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/routes/WorkPeriods/components/PaymentError/index.jsx b/src/routes/WorkPeriods/components/PaymentError/index.jsx new file mode 100644 index 0000000..f437efe --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentError/index.jsx @@ -0,0 +1,59 @@ +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 + * @returns {JSX.Element} + */ +const PaymentError = ({ className, errorDetails, isImportant = true }) => { + 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, +}; + +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..b817bd1 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentError/styles.module.scss @@ -0,0 +1,30 @@ +@import "styles/variables"; + +.container { + display: inline-block; + position: relative; +} + +.icon { + display: inline-block; + padding: 2px 0 0; + width: 20px; + height: 20px; + border-radius: 10px; + line-height: 18px; + 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 782a9df..1354d98 100644 --- a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss @@ -37,7 +37,7 @@ } .failed { - background: #e90c5a; + background: $error-color; } .undefined { diff --git a/src/routes/WorkPeriods/components/PaymentTotal/index.jsx b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx new file mode 100644 index 0000000..4c7952d --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx @@ -0,0 +1,74 @@ +/* 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 + * @returns {JSX.Element} + */ +const PaymentTotal = ({ className, payments, paymentTotal, daysPaid }) => { + 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, +}; + +export default PaymentTotal; diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/styles.module.scss b/src/routes/WorkPeriods/components/PaymentTotal/styles.module.scss similarity index 62% rename from src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/styles.module.scss rename to src/routes/WorkPeriods/components/PaymentTotal/styles.module.scss index 5e066c6..4fe846e 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentTotal/styles.module.scss @@ -2,19 +2,10 @@ .container { position: relative; - - .dropdown-arrow { - display: none; - } + display: inline-block; } .paymentTotal { - width: 90px; - text-align: right; - white-space: nowrap; -} - -.paymentTotalText { display: inline-block; line-height: 16px; @@ -27,7 +18,3 @@ .daysPaid { color: #aaa; } - -.popup { - z-index: 1; -} 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/PaymentsPopup/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss similarity index 75% rename from src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss rename to src/routes/WorkPeriods/components/PaymentsList/styles.module.scss index 94a017c..a878a33 100644 --- a/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss @@ -1,12 +1,7 @@ @import "styles/mixins"; .container { - position: relative; - border-radius: 8px; - padding: 18px 25px; - box-shadow: 0 5px 35px 5px rgba(21, 21, 22, 0.1), - 0 10px 14px 0 rgba(21, 21, 22, 0.3); - background: #fff; + display: block; } .title { diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx index 08cc8e0..939f998 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -2,9 +2,11 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ import React, { useCallback, useRef } from "react"; import PT from "prop-types"; -import styles from "./styles.module.scss"; import { currencyFormatter, formatChallengeUrl } from "utils/formatters"; +import { PAYMENT_STATUS } from "constants/workPeriods"; import PaymentStatus from "../PaymentStatus"; +import styles from "./styles.module.scss"; +import PaymentError from "../PaymentError"; const PaymentsListItem = ({ item }) => { const inputRef = useRef(); @@ -45,8 +47,16 @@ const PaymentsListItem = ({ item }) => { {item.days} {currencyFormatter.format(item.amount)} - - + +
+ + {item.status === PAYMENT_STATUS.FAILED && ( + + )} +
); @@ -60,6 +70,7 @@ PaymentsListItem.propTypes = { 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 8d65bfb..bee0edd 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss @@ -23,41 +23,32 @@ } } -.iconLink { +.iconLink, +.iconCopyLink, +.iconOpenLink { display: inline-block; vertical-align: -2px; - margin-right: 5px; 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 { - display: inline-block; - vertical-align: -2px; 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 { - display: inline-block; - vertical-align: -2px; 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"); } @@ -67,6 +58,19 @@ 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 2d760ed..0000000 --- a/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx +++ /dev/null @@ -1,47 +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) => ( - - ))} - -
Challenge IDWeekly RateDaysAmountStatus
-
- ); -}; - -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/PeriodFilters/index.jsx b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx index 4c9a543..606555b 100644 --- a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx @@ -3,16 +3,18 @@ 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, @@ -33,7 +35,15 @@ 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) => { @@ -93,6 +103,16 @@ const PeriodFilters = ({ className }) => { value={paymentStatuses} /> +
+ + +
- {HEAD_CELLS.map(({ id, label, disableSort }) => ( + {HEAD_CELLS.map(({ id, className, label, disableSort }) => ( -
+
{label} {!disableSort && ( { }, [dispatch, pagination.pageNumber, pagination.pageSize, sorting]); useEffect(() => { + dispatch(updateQueryFromState(true)); return globalHistory.listen(({ action, location }) => { if (action === "POP") { dispatch(updateStateFromQuery(location.search)); diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx index aca9171..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} - + )} + @@ -98,12 +106,13 @@ PeriodsHistoryItem.propTypes = { id: PT.string.isRequired, startDate: PT.oneOfType([PT.string, PT.number]).isRequired, endDate: PT.oneOfType([PT.string, PT.number]).isRequired, - payments: PT.array, weeklyRate: PT.number, }).isRequired, data: PT.shape({ daysWorked: PT.number.isRequired, daysPaid: PT.number.isRequired, + paymentErrorLast: PT.object, + payments: PT.array, paymentStatus: PT.string.isRequired, paymentTotal: PT.number.isRequired, }).isRequired, diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss index c91363d..d85f175 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss @@ -29,13 +29,21 @@ .paymentTotal { padding: 6px 12px; - line-height: 26px; + text-align: right; +} + +.paymentError { + margin-right: 5px; } .paymentTotalContainer { position: relative; } +.paymentStatus { + padding: 0 12px; +} + .daysWorked { padding: 4px 10px; } diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/index.jsx deleted file mode 100644 index ebbfd13..0000000 --- a/src/routes/WorkPeriods/components/PeriodsHistoryPaymentTotal/index.jsx +++ /dev/null @@ -1,90 +0,0 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -import React, { useCallback, useRef, useState } from "react"; -import { usePopper } from "react-popper"; -import PT from "prop-types"; -import cn from "classnames"; -import PaymentsPopup from "../PaymentsPopup"; -import { useClickOutside } from "utils/hooks"; -import { currencyFormatter } from "utils/formatters"; -import compStyles from "./styles.module.scss"; - -const PeriodsHistoryPaymentTotal = ({ - className, - payments, - paymentTotal, - daysPaid, -}) => { - 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/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 273a3a5..de5fdf2 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -19,16 +19,17 @@ 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"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index b50c3b5..bfb2e1c 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -325,20 +325,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. * diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index f8b79bc..b49849c 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -33,6 +33,7 @@ const initPagination = () => ({ const initFilters = () => ({ dateRange: getWeekByDate(moment()), + onlyFailedPayments: false, paymentStatuses: {}, // all disabled by default userHandle: "", }); @@ -532,7 +533,10 @@ const actionHandlers = { }, }; }, - [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) { @@ -547,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) { @@ -563,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) { @@ -594,6 +598,20 @@ 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, + }, + }; + }, [ACTION_TYPE.WP_TOGGLE_PERIOD]: (state, periodId) => { let isSelectedPeriodsAll = state.isSelectedPeriodsAll; let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; @@ -694,13 +712,14 @@ function updateStateFromQuery(queryStr, state) { let updatePagination = false; let updateSorting = false; const { filters, pagination, sorting } = state; - // checking payment statuses 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 = {}; @@ -726,21 +745,32 @@ function updateStateFromQuery(queryStr, state) { 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 - params.userHandle = params.userHandle || ""; - if (params.userHandle !== filters.userHandle) { - filters.userHandle = params.userHandle.slice(0, 256); + const userHandle = params.userHandle?.slice(0, 256) || ""; + if (userHandle !== filters.userHandle) { + filters.userHandle = userHandle; updateFilters = true; } // checking sorting criteria - const criteria = params.criteria?.toUpperCase(); - if (criteria in SORT_BY && criteria !== 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 - if (params.order in SORT_ORDER && params.order !== sorting.order) { - sorting.order = params.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 diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index 07abf0e..01d0c1e 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -10,6 +10,7 @@ import { PAYMENT_STATUS_MAP, API_FIELDS_QUERY, JOB_NAME_NONE, + API_CHALLENGE_PAYMENT_STATUS, } from "constants/workPeriods"; import { extractJobName, @@ -54,6 +55,7 @@ export const loadWorkPeriodsPage = async (dispatch, getState) => { 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), @@ -62,7 +64,7 @@ export const loadWorkPeriodsPage = async (dispatch, getState) => { // For parameter description see: // https://topcoder-platform.github.io/taas-apis/#/ResourceBookings/get_resourceBookings - const [promise, cancelSource] = services.fetchResourceBookings({ + const params = { fields: API_FIELDS_QUERY, page: pagination.pageNumber, perPage: pagination.pageSize, @@ -70,10 +72,14 @@ export const loadWorkPeriodsPage = async (dispatch, getState) => { sortOrder, // we only want to show Resource Bookings with status "placed" status: RESOURCE_BOOKING_STATUS.PLACED, - ["workPeriods.userHandle"]: filters.userHandle, + ["workPeriods.userHandle"]: userHandle, ["workPeriods.startDate"]: startDate.format(DATE_FORMAT_API), ["workPeriods.paymentStatus"]: paymentStatuses, - }); + }; + if (onlyFailedPayments) { + params["workPeriods.payments.status"] = API_CHALLENGE_PAYMENT_STATUS.FAILED; + } + const [promise, cancelSource] = services.fetchResourceBookings(params); dispatch(actions.loadWorkPeriodsPagePending(cancelSource)); let totalCount, periods, pageCount; try { 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/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 b722470..0811244 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -148,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 8e67618..ec97f28 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -16,13 +16,15 @@ import { */ export function makeUrlQuery(state) { const { filters, pagination, sorting } = state; - const { dateRange, paymentStatuses, userHandle } = filters; + 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(), - userHandle, + onlyFailedPayments: onlyFailedPayments ? "y" : "", + userHandle: encodeURIComponent(userHandle), criteria: criteria.toLowerCase(), order, pageNumber, @@ -63,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). @@ -70,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]; } /** @@ -115,36 +152,6 @@ export function createAssignedBillingAccountOption(accountId) { return { value: accountId, label: ` (${accountId})` }; } -export function normalizeDetailsPeriodItems(items) { - const periods = []; - for (let item of items) { - let payments = item.payments || []; - periods.push({ - id: item.id, - startDate: item.startDate ? moment(item.startDate).valueOf() : 0, - endDate: item.endDate ? moment(item.endDate).valueOf() : 0, - payments: payments.length ? normalizeDetailsPayments(payments) : payments, - weeklyRate: item.memberRate, - data: normalizePeriodData(item), - }); - } - periods.sort(sortByStartDate); - return periods; -} - -function normalizeDetailsPayments(payments) { - for (let payment of payments) { - payment.status = - API_CHALLENGE_PAYMENT_STATUS_MAP[payment.status] || - PAYMENT_STATUS.UNDEFINED; - } - return payments; -} - -export function normalizePaymentStatus(paymentStatus) { - return API_PAYMENT_STATUS_MAP[paymentStatus]; -} - export function sortByStartDate(itemA, itemB) { return itemA.startDate - itemB.startDate; }