diff --git a/src/components/ActionsMenu/index.jsx b/src/components/ActionsMenu/index.jsx index 5507631..ab08abe 100644 --- a/src/components/ActionsMenu/index.jsx +++ b/src/components/ActionsMenu/index.jsx @@ -14,6 +14,7 @@ import compStyles from "./styles.module.scss"; * @param {Object} props component properties * @param {'primary'|'error'|'warning'} [props.handleColor] menu handle color * @param {'small'|'medium'} [props.handleSize] menu handle size + * @param {string} [props.handleText] text to show inside menu handle * @param {Array} props.items menu items * @param {'absolute'|'fixed'} [props.popupStrategy] popup positioning strategy * @param {boolean} [props.stopClickPropagation] whether to stop click event propagation @@ -22,6 +23,7 @@ import compStyles from "./styles.module.scss"; const ActionsMenu = ({ handleColor = "primary", handleSize = "small", + handleText, items = [], popupStrategy = "absolute", stopClickPropagation = false, @@ -89,6 +91,7 @@ const ActionsMenu = ({ {isOpen && ( span { + + .iconArrowDown { + margin-left: 8px; + } + } } .iconArrowDown { display: inline-block; width: 12px; height: 8px; - margin-left: 8px; } .handleMenuOpen { diff --git a/src/components/ProjectName/index.jsx b/src/components/ProjectName/index.jsx index 2550af0..29c0314 100644 --- a/src/components/ProjectName/index.jsx +++ b/src/components/ProjectName/index.jsx @@ -13,11 +13,7 @@ const ProjectName = ({ className, projectId }) => { const projectName = getName(projectId) || projectId; - return ( - - {projectName} - - ); + return {projectName}; }; ProjectName.propTypes = { diff --git a/src/components/ProjectName/styles.module.scss b/src/components/ProjectName/styles.module.scss index b61d947..fdfaa0a 100644 --- a/src/components/ProjectName/styles.module.scss +++ b/src/components/ProjectName/styles.module.scss @@ -2,9 +2,6 @@ .container { display: inline-block; - max-width: 20em; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; @include roboto-medium; } diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 59bf842..8243fc3 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -31,6 +31,7 @@ export const TAAS_TEAM_API_URL = `${API.V5}/taas-teams`; export const DATE_FORMAT_API = "YYYY-MM-DD"; export const DATE_FORMAT_ISO = "YYYY-MM-DD"; export const DATE_FORMAT_UI = "MMM DD, YYYY"; +export const DATETIME_FORMAT_UI = "MMM DD, YYYY h:mm a"; // Field names that are required to be retrieved for display, filtering and sorting. export const API_REQUIRED_FIELDS = [ @@ -167,4 +168,6 @@ export const ALERT_MESSAGE_MAP = { [ALERT.LAST_BOOKING_WEEK]: "Last Booking Week", }; +export const SERVER_DATA_UPDATE_DELAY = 3000; + export const DAYS_WORKED_HARD_LIMIT = 10; diff --git a/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx b/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx index 8cc1a52..8ead0a1 100644 --- a/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx @@ -3,10 +3,7 @@ import { useDispatch } from "react-redux"; import PT from "prop-types"; import Modal from "components/Modal"; import Spinner from "components/Spinner"; -import { makeToast } from "components/ToastrMessage"; -import { setWorkPeriodPaymentData } from "store/actions/workPeriods"; -import { loadWorkPeriodAfterPaymentCancel } from "store/thunks/workPeriods"; -import { cancelWorkPeriodPayment } from "services/workPeriods"; +import { cancelWorkPeriodPayment } from "store/thunks/workPeriods"; /** * Displays a Cancel button. Shows a modal with payment cancelling confirmation @@ -16,19 +13,16 @@ import { cancelWorkPeriodPayment } from "services/workPeriods"; * @param {Object} props.payment payment object with id, workPeriodId and status * @param {() => void} props.removeModal function called when the closing * animation of the modal is finished - * @param {number} [props.timeout] timeout the delay after cancelling payment - * after which an attempt will be made to update working period's data from the server * @returns {JSX.Element} */ -const PaymentModalCancel = ({ payment, removeModal, timeout = 3000 }) => { +const PaymentModalCancel = ({ payment, removeModal }) => { const [isModalOpen, setIsModalOpen] = useState(true); - const [isCancelPending, setIsCancelPending] = useState(false); - const [isCancelSuccess, setIsCancelSuccess] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); const dispatch = useDispatch(); const { id: paymentId, workPeriodId: periodId } = payment; const onApprove = useCallback(() => { - setIsCancelPending(true); + setIsProcessing(true); }, []); const onDismiss = useCallback(() => { @@ -36,41 +30,18 @@ const PaymentModalCancel = ({ payment, removeModal, timeout = 3000 }) => { }, []); useEffect(() => { - if (!isCancelPending) { + if (!isProcessing) { return; } - cancelWorkPeriodPayment(paymentId) - .then((paymentData) => { - dispatch(setWorkPeriodPaymentData(paymentData)); - setIsCancelSuccess(true); - }) - .catch((error) => { - makeToast(error.toString()); - setIsCancelPending(false); - }); - }, [isCancelPending, paymentId, dispatch]); - - useEffect(() => { - let timeoutId = 0; - if (!isCancelSuccess) { - return; - } - timeoutId = window.setTimeout(async () => { - timeoutId = 0; - await dispatch(loadWorkPeriodAfterPaymentCancel(periodId, paymentId)); - setIsModalOpen(false); - setIsCancelSuccess(false); - setIsCancelPending(false); - }, timeout); - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [isCancelSuccess, paymentId, periodId, timeout, dispatch]); + (async function () { + let ok = await dispatch(cancelWorkPeriodPayment(periodId, paymentId)); + setIsModalOpen(!ok); + setIsProcessing(false); + })(); + }, [isProcessing, paymentId, periodId, dispatch]); let title, controls; - if (isCancelPending) { + if (isProcessing) { controls = null; title = "Marking as cancelled..."; } else { @@ -83,13 +54,14 @@ const PaymentModalCancel = ({ payment, removeModal, timeout = 3000 }) => { approveText="Mark as cancelled" dismissText="Cancel cancelling" title={title} + isDisabled={isProcessing} isOpen={isModalOpen} controls={controls} onApprove={onApprove} onClose={removeModal} onDismiss={onDismiss} > - {isCancelPending ? ( + {isProcessing ? ( ) : ( `Cancelling payment here will only mark it as cancelled in TaaS system. @@ -107,7 +79,6 @@ PaymentModalCancel.propTypes = { workPeriodId: PT.string.isRequired, }).isRequired, removeModal: PT.func.isRequired, - timeout: PT.number, }; export default PaymentModalCancel; diff --git a/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx b/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx index 14262b9..fd57aa4 100644 --- a/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; import moment from "moment"; @@ -7,11 +8,8 @@ import Modal from "components/Modal"; import ProjectName from "components/ProjectName"; import Spinner from "components/Spinner"; import SelectField from "components/SelectField"; -import { makeToast } from "components/ToastrMessage"; -import { - fetchBillingAccounts, - patchWorkPeriodPayments, -} from "services/workPeriods"; +import { fetchBillingAccounts } from "services/workPeriods"; +import { updatePaymentsBillingAccount } from "store/thunks/workPeriods"; import { createAssignedBillingAccountOption, normalizeBillingAccounts, @@ -42,6 +40,21 @@ const PaymentModalUpdateBA = ({ payments = [], period, removeModal }) => { const [billingAccountsDisabled, setBillingAccountsDisabled] = useState(true); const [billingAccountsError, setBillingAccountsError] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + const dispatch = useDispatch(); + + const accountIdMap = {}; + for (let payment of payments) { + accountIdMap[payment.billingAccountId] = true; + } + const accountIds = Object.keys(accountIdMap); + + const onApprove = useCallback(() => { + setIsProcessing(true); + }, []); + + const onDismiss = useCallback(() => { + setIsModalOpen(false); + }, []); useEffect(() => { const [bilAccsPromise] = fetchBillingAccounts(period.projectId); @@ -93,38 +106,14 @@ const PaymentModalUpdateBA = ({ payments = [], period, removeModal }) => { if (!isProcessing) { return; } - const paymentsUpdated = []; - for (let { id } of payments) { - paymentsUpdated.push({ id, billingAccountId }); - } - patchWorkPeriodPayments(paymentsUpdated) - .then(() => { - makeToast( - "Billing account was successfully updated for all the payments", - "success" - ); - setIsModalOpen(false); - }) - .catch((error) => { - makeToast(error.toString()); - }) - .finally(() => { - setIsProcessing(false); - }); - }, [billingAccountId, isProcessing, payments, period.id]); - - const onApprove = useCallback(() => { - setIsProcessing(true); - }, []); - - const onDismiss = useCallback(() => { - setIsModalOpen(false); - }, []); - - const accountIdsHash = {}; - for (let payment of payments) { - accountIdsHash[payment.billingAccountId] = true; - } + (async function () { + let ok = await dispatch( + updatePaymentsBillingAccount(period.id, billingAccountId) + ); + setIsModalOpen(!ok); + setIsProcessing(false); + })(); + }, [billingAccountId, isProcessing, period.id, dispatch]); return ( { Current BA(s) used: - {Object.keys(accountIdsHash).join(", ") || "-"} + {accountIds.join(", ") || "-"} diff --git a/src/routes/WorkPeriods/components/PaymentsList/index.jsx b/src/routes/WorkPeriods/components/PaymentsList/index.jsx index bf9f9d7..fffa9d6 100644 --- a/src/routes/WorkPeriods/components/PaymentsList/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsList/index.jsx @@ -19,6 +19,7 @@ const PaymentsList = ({ className, daysPaid, daysWorked, payments }) => ( Weekly Rate Days Amount + Created At Status diff --git a/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss index c8b9cec..cf8c4e9 100644 --- a/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss @@ -25,6 +25,7 @@ table.paymentsList { background: #f4f4f4; &:first-child, + &.createdAt, &.paymentStatus { text-align: left; } diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx index dbb7800..360b8eb 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -5,7 +5,11 @@ import PT from "prop-types"; import PaymentActions from "../PaymentActions"; import PaymentError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; -import { currencyFormatter, formatChallengeUrl } from "utils/formatters"; +import { + currencyFormatter, + formatChallengeUrl, + formatDateTimeAsLocal, +} from "utils/formatters"; import { PAYMENT_STATUS } from "constants/workPeriods"; import styles from "./styles.module.scss"; @@ -55,6 +59,9 @@ const PaymentsListItem = ({ daysPaid, daysWorked, item }) => { {item.days} {currencyFormatter.format(item.amount)} + + {formatDateTimeAsLocal(item.createdAt)} +
@@ -84,6 +91,7 @@ PaymentsListItem.propTypes = { id: PT.oneOfType([PT.string, PT.number]).isRequired, amount: PT.number.isRequired, challengeId: PT.oneOfType([PT.string, PT.number]), + createdAt: PT.number.isRequired, days: PT.number.isRequired, memberRate: PT.number.isRequired, status: PT.string.isRequired, diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss index bee0edd..1fef2f8 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss @@ -58,6 +58,10 @@ text-align: right; } +.createdAt { + text-align: left; +} + .paymentStatus { white-space: nowrap; } diff --git a/src/routes/WorkPeriods/components/PeriodActions/index.jsx b/src/routes/WorkPeriods/components/PeriodActions/index.jsx index 2de5b69..b531657 100644 --- a/src/routes/WorkPeriods/components/PeriodActions/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodActions/index.jsx @@ -13,38 +13,37 @@ import styles from "./styles.module.scss"; * @returns {JSX.Element} */ const PeriodActions = ({ className, period, periodData }) => { - const [isAddPaymentModalOpen, setIsAddPaymentModalOpen] = useState(false); - const [isUpdateBAModalOpen, setIsUpdateBAModalOpen] = useState(false); + const [isOpenAddPaymentModal, setIsOpenAddPaymentModal] = useState(false); + const [isOpenUpdateBAModal, setIsOpenUpdateBAModal] = useState(false); const payments = periodData.payments; - const openAddPaymentModal = useCallback(() => { - setIsAddPaymentModalOpen(true); - }, []); - const closeAddPaymentModal = useCallback(() => { - setIsAddPaymentModalOpen(false); - }, []); - - const openUpdateBAModal = useCallback(() => { - setIsUpdateBAModalOpen(true); + setIsOpenAddPaymentModal(false); }, []); const closeUpdateBAModal = useCallback(() => { - setIsUpdateBAModalOpen(false); + setIsOpenUpdateBAModal(false); }, []); const actions = useMemo(() => { let actions = [ - { label: "Additional Payment", action: openAddPaymentModal }, + { + label: "Additional Payment", + action() { + setIsOpenAddPaymentModal(true); + }, + }, ]; if (payments?.length) { actions.push({ label: "Update BA for payments", - action: openUpdateBAModal, + action() { + setIsOpenUpdateBAModal(true); + }, }); } return actions; - }, [payments, openAddPaymentModal, openUpdateBAModal]); + }, [payments]); return (
@@ -53,13 +52,13 @@ const PeriodActions = ({ className, period, periodData }) => { popupStrategy="fixed" stopClickPropagation={true} /> - {isAddPaymentModalOpen && ( + {isOpenAddPaymentModal && ( )} - {isUpdateBAModalOpen && ( + {isOpenUpdateBAModal && ( { )} tooltipClassName={styles.tooltip} > - {alerts - ? alerts.map((alertId) => ALERT_MESSAGE_MAP[alertId]).join(", ") - : "None"} + {alerts ? "" : "None"} ); }; diff --git a/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss b/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss index ed78d8f..a4a954d 100644 --- a/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss @@ -21,7 +21,6 @@ &::before { content: "!"; display: inline-block; - margin-right: 4px; border: 2px solid $text-color; border-radius: 7px; padding: 1px 0 0; diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 9d08f86..ab7103b 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -116,11 +116,17 @@ const PeriodItem = ({ [item.jobId] ); - const projectId = useMemo( + const projectIdAndTeamName = useMemo( () => ( Project ID:  {item.projectId} +
+ Team Name:  +
), [item.projectId] @@ -171,6 +177,7 @@ const PeriodItem = ({ - - + + {formatDate(item.bookingStart)} diff --git a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss index 576ed4f..03d479b 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss @@ -91,6 +91,21 @@ td.teamName { white-space: nowrap; } +.tooltipProjectName { + font-weight: normal; +} + +.projectNameContainer { + display: block; +} + +.projectName { + display: block; + width: 85px; + text-overflow: ellipsis; + overflow: hidden; +} + td.startDate, td.endDate { padding-left: 10px; diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 68bb3bc..26fb49d 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -24,6 +24,7 @@ export const WP_SET_ALERT_OPTION = "WP_SET_ALERT_OPTION"; 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_PERIOD_PAYMENTS = "WP_SET_PERIOD_PAYMENTS"; 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"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index 8ebf531..223cc24 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -315,6 +315,11 @@ export const setWorkPeriodPaymentData = (paymentData) => ({ payload: paymentData, }); +export const setWorkPeriodPayments = (periodId, payments) => ({ + type: ACTION_TYPE.WP_SET_PERIOD_PAYMENTS, + payload: { periodId, payments }, +}); + /** * Creates an action to change working days for specific working period. * diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index f4ff2dd..13da1d7 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -653,6 +653,21 @@ const actionHandlers = { periodsData: [periodsData], }; }, + [ACTION_TYPE.WP_SET_PERIOD_PAYMENTS]: (state, { periodId, payments }) => { + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + periodsData[periodId] = { + ...periodData, + payments, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, [ACTION_TYPE.WP_SET_PAYMENT_DATA]: (state, paymentData) => { const periodId = paymentData.workPeriodId; const periodsData = state.periodsData[0]; diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index eff13d5..fc5f51d 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -4,14 +4,16 @@ import * as actions from "store/actions/workPeriods"; import * as selectors from "store/selectors/workPeriods"; import * as services from "services/workPeriods"; import { - SORT_BY_MAP, + API_CHALLENGE_PAYMENT_STATUS, + API_FIELDS_QUERY, API_SORT_BY, DATE_FORMAT_API, PAYMENT_STATUS_MAP, - API_FIELDS_QUERY, - API_CHALLENGE_PAYMENT_STATUS, + SERVER_DATA_UPDATE_DELAY, + SORT_BY_MAP, } from "constants/workPeriods"; import { + delay, extractResponseData, extractResponsePagination, replaceItems, @@ -20,6 +22,7 @@ import { makeUrlQuery, normalizeBillingAccounts, normalizeDetailsPeriodItems, + normalizePaymentData, normalizePeriodData, normalizePeriodItems, } from "utils/workPeriods"; @@ -33,43 +36,91 @@ import { import { RESOURCE_BOOKING_STATUS, WORK_PERIODS_PATH } from "constants/index.js"; import { currencyFormatter } from "utils/formatters"; -export const loadWorkPeriodAfterPaymentCancel = - (periodId, paymentId) => async (dispatch, getState) => { +/** + * A thunk that cancels specific working period payment, reloads WP data + * and updates store's state after certain delay. + * + * @param {string} periodId working period id + * @param {string} paymentId working period's payment id + * @param {number} [periodUpdateDelay] update delay for period data + * @returns {function} + */ +export const cancelWorkPeriodPayment = + (periodId, paymentId, periodUpdateDelay = SERVER_DATA_UPDATE_DELAY) => + async (dispatch) => { + let paymentData = null; + let errorMessage = null; + try { + paymentData = await services.cancelWorkPeriodPayment(paymentId); + paymentData = normalizePaymentData(paymentData); + } catch (error) { + errorMessage = error.toString(); + } + if (errorMessage) { + makeToast(errorMessage); + return false; + } + dispatch(actions.setWorkPeriodPaymentData(paymentData)); + let periodData; + [periodData, errorMessage] = await dispatch( + loadWorkPeriodData(periodId, periodUpdateDelay) + ); + if (errorMessage) { + makeToast("Failed to reload working period data. " + errorMessage); + } else if (periodData) { + let userHandle = periodData.userHandle; + let amount = null; + for (let payment of periodData.payments) { + if (payment.id === paymentId) { + amount = currencyFormatter.format(payment.amount); + break; + } + } + makeToast( + `Payment ${amount} for ${userHandle} was marked as "cancelled"`, + "success" + ); + } + return true; + }; + +/** + * A thunk that loads specific working period data and updates store's state. + * + * @param {string} periodId working period id + * @param {number} [updateDelay] update delay in milliseconds + * @returns {function} + */ +export const loadWorkPeriodData = + (periodId, updateDelay = 0) => + async (dispatch, getState) => { + if (updateDelay > 0) { + await delay(updateDelay); + } let [periodsData] = selectors.getWorkPeriodsData(getState()); periodsData[periodId]?.cancelSource?.cancel(); const [promise, source] = services.fetchWorkPeriod(periodId); dispatch(actions.setWorkPeriodDataPending(periodId, source)); - let periodData = null; let userHandle = null; + let periodData = null; let errorMessage = null; try { const data = await promise; - periodData = normalizePeriodData(data); userHandle = data.userHandle; + periodData = normalizePeriodData(data); } catch (error) { if (!axios.isCancel(error)) { errorMessage = error.toString(); } } if (periodData) { - let amount = null; - for (let payment of periodData.payments) { - if (payment.id === paymentId) { - amount = currencyFormatter.format(payment.amount); - break; - } - } dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); - makeToast( - `Payment ${amount} for ${userHandle} was marked as "cancelled"`, - "success" - ); + return [{ ...periodData, userHandle }, null]; } else if (errorMessage) { dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); - makeToast( - `Failed to load data for working period ${periodId}.\n` + errorMessage - ); + return [null, errorMessage]; } + return [null, null]; }; /** @@ -238,6 +289,86 @@ export const toggleWorkPeriodDetails = } }; +/** + * A thunk that updates the billing accounts for all the payments from the + * specific working period. + * + * @param {string} periodId working period id + * @param {number} billingAccountId desired billing account id + * @returns {function} + */ +export const updatePaymentsBillingAccount = + (periodId, billingAccountId) => async (dispatch, getState) => { + let [periodsData] = selectors.getWorkPeriodsData(getState()); + let periodData = periodsData[periodId]; + if (!periodData) { + return true; // no period to update + } + let paymentsToUpdate = []; + for (let payment of periodData.payments) { + if (payment.billingAccountId !== billingAccountId) { + paymentsToUpdate.push({ id: payment.id, billingAccountId }); + } + } + if (!paymentsToUpdate.length) { + makeToast( + "All payments have desired billing account. Nothing to update.", + "success" + ); + return true; + } + let paymentsData = null; + let errorMessage = null; + try { + paymentsData = await services.patchWorkPeriodPayments(paymentsToUpdate); + } catch (error) { + errorMessage = error.toString(); + } + if (errorMessage) { + makeToast(errorMessage); + return false; + } + let paymentsNotUpdated = []; + let paymentsUpdated = new Map(); + for (let payment of paymentsData) { + if ("error" in payment || payment.billingAccountId !== billingAccountId) { + paymentsNotUpdated.push(payment); + } else { + paymentsUpdated.set(payment.id, payment); + } + } + periodData = periodsData[periodId]; + if (!periodData) { + return true; // no period to update + } + if (paymentsUpdated.size) { + let payments = []; + let paymentsOld = periodData.payments; + for (let i = 0, len = paymentsOld.length; i < len; i++) { + let paymentOld = paymentsOld[i]; + if (paymentsUpdated.has(paymentOld.id)) { + // We update only billingAccountId because other payment properties + // may have been updated on the server and as a result the UI state + // may become inconsistent, i.e. WP properties like status and + // total paid may become inconsisten with payments' properties. + payments.push({ ...paymentOld, billingAccountId }); + } else { + payments.push(paymentOld); + } + } + dispatch(actions.setWorkPeriodPayments(periodId, payments)); + } + if (paymentsNotUpdated.length) { + makeToast("Could not update billing account for some payments."); + return false; + } + makeToast( + "Billing account was successfully updated for all the payments.", + "success" + ); + return true; + }; + /** * * @param {string} rbId diff --git a/src/utils/formatters.js b/src/utils/formatters.js index 5dca94a..955cd91 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -1,6 +1,10 @@ import moment from "moment"; import isNumber from "lodash/isNumber"; -import { DATE_FORMAT_UI, PAYMENT_STATUS_LABELS } from "constants/workPeriods"; +import { + DATETIME_FORMAT_UI, + DATE_FORMAT_UI, + PAYMENT_STATUS_LABELS, +} from "constants/workPeriods"; import { PLATFORM_WEBSITE_URL, TAAS_BASE_PATH, @@ -29,6 +33,16 @@ export function formatDate(date) { return date ? moment(date).format(DATE_FORMAT_UI) : "-"; } +/** + * Formats the provided time in UTC-0 as time in local timezone. + * + * @param {number} dateTime number of milliseconds since UTC epoch + * @returns {string} + */ +export function formatDateTimeAsLocal(dateTime) { + return moment(dateTime).format(DATETIME_FORMAT_UI); +} + /** * Returns a string denoting whether the specified start date corresponds to the * current period or future period. diff --git a/src/utils/misc.js b/src/utils/misc.js index e39e6cb..cc49429 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -141,6 +141,17 @@ export const buildRequestQuery = (params) => { return queryParams.join("&"); }; +/** + * Function that returns a promise which resolves after the provided delay. + * + * @param {number} ms number of milliseconds + * @returns {Promise} + */ +export const delay = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + export const extractResponsePagination = ({ headers }) => ({ totalCount: +headers["x-total"] || 0, pageCount: +headers["x-total-pages"] || 0, @@ -242,4 +253,3 @@ export function validateAmount(value) { amount < 1e5 ); } - diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 8a4d091..7743cbd 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -3,7 +3,6 @@ import { ALERT, API_CHALLENGE_PAYMENT_STATUS_MAP, API_PAYMENT_STATUS_MAP, - DATE_FORMAT_API, DATE_FORMAT_ISO, PAYMENT_STATUS, REASON_DISABLED, @@ -127,7 +126,7 @@ export function makeUrlQuery(state) { const { pageNumber, pageSize } = pagination; const { criteria, order } = sorting; const params = { - startDate: dateRange[0].format(DATE_FORMAT_API), + startDate: dateRange[0].format(DATE_FORMAT_ISO), paymentStatuses: Object.keys(paymentStatuses).join(",").toLowerCase(), alertOptions: Object.keys(alertOptions).join(",").toLowerCase(), onlyFailedPayments: onlyFailedPayments ? "y" : "", @@ -214,22 +213,50 @@ export function normalizePeriodData(period) { paymentStatus: normalizePaymentStatus(period.paymentStatus), paymentTotal: +period.paymentTotal || 0, }; - let payments = period.payments; - if (payments) { - let lastFailedPayment = null; - for (let payment of payments) { - payment.createdAt = moment(payment.createdAt).valueOf(); - payment.status = normalizeChallengePaymentStatus(payment.status); - if (payment.status === PAYMENT_STATUS.FAILED) { - lastFailedPayment = payment; - } + if (period.payments) { + normalizePeriodPayments(period.payments, data); + } + return data; +} + +/** + * Normalizes working period payment data object by mutating it. + * + * @param {Object} payment working period payment data object + * @returns {Object} working period payment data object + */ +export function normalizePaymentData(payment) { + payment.createdAt = moment.utc(payment.createdAt).valueOf(); + payment.status = normalizeChallengePaymentStatus(payment.status); + return payment; +} + +/** + * Normalizes working period payments. + * + * @param {Array} payments array of payment data + * @param {Object} [data] period data object to populate + * @returns {Array} array with normalized payments data + */ +export function normalizePeriodPayments(payments, data) { + let lastFailedPayment = null; + for (let payment of payments) { + payment.createdAt = moment.utc(payment.createdAt).valueOf(); + payment.status = normalizeChallengePaymentStatus(payment.status); + if (payment.status === PAYMENT_STATUS.FAILED) { + lastFailedPayment = payment; } + } + payments.sort(sortPaymentsByCreatedAt); + if (data) { data.paymentErrorLast = lastFailedPayment?.statusDetails; - data.payments = payments.sort( - (paymentA, paymentB) => paymentA.createdAt - paymentB.createdAt - ); + data.payments = payments; } - return data; + return payments; +} + +function sortPaymentsByCreatedAt(paymentA, paymentB) { + return paymentA.createdAt - paymentB.createdAt; } export function normalizeChallengePaymentStatus(paymentStatus) {