From ff9e07da57225b39cb6ccae264f406df0166747d Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Tue, 10 Aug 2021 23:58:33 +0300 Subject: [PATCH 1/5] Implemented reloading of working period data after BA update. --- .../components/PaymentModalCancel/index.jsx | 1 + .../components/PaymentModalUpdateBA/index.jsx | 65 ++++------ .../components/PeriodActions/index.jsx | 33 +++-- src/store/thunks/workPeriods.js | 122 ++++++++++++++---- src/utils/misc.js | 11 ++ src/utils/workPeriods.js | 42 ++++-- 6 files changed, 183 insertions(+), 91 deletions(-) diff --git a/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx b/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx index 8cc1a52..86b108e 100644 --- a/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx @@ -83,6 +83,7 @@ const PaymentModalCancel = ({ payment, removeModal, timeout = 3000 }) => { approveText="Mark as cancelled" dismissText="Cancel cancelling" title={title} + isDisabled={isCancelPending} isOpen={isModalOpen} controls={controls} onApprove={onApprove} diff --git a/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx b/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx index 14262b9..4971fa8 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, 5000) + ); + 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/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 && ( async (dispatch, getState) => { - 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 errorMessage = null; - try { - const data = await promise; - periodData = normalizePeriodData(data); - userHandle = data.userHandle; - } catch (error) { - if (!axios.isCancel(error)) { - errorMessage = error.toString(); - } +export const loadWorkPeriodData = (periodId) => async (dispatch, getState) => { + let [periodsData] = selectors.getWorkPeriodsData(getState()); + periodsData[periodId]?.cancelSource?.cancel(); + const [promise, source] = services.fetchWorkPeriod(periodId); + dispatch(actions.setWorkPeriodDataPending(periodId, source)); + let userHandle = null; + let periodData = null; + let errorMessage = null; + try { + const data = await promise; + userHandle = data.userHandle; + periodData = normalizePeriodData(data); + } catch (error) { + if (!axios.isCancel(error)) { + errorMessage = error.toString(); } + } + if (periodData) { + dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); + return [{ ...periodData, userHandle }, null]; + } else if (errorMessage) { + dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); + return [null, errorMessage]; + } + return [null, null]; +}; + +export const loadWorkPeriodAfterPaymentCancel = + (periodId, paymentId) => async (dispatch) => { + let [periodData, error] = await dispatch(loadWorkPeriodData(periodId)); if (periodData) { + let userHandle = periodData.userHandle; let amount = null; for (let payment of periodData.payments) { if (payment.id === paymentId) { @@ -59,16 +73,12 @@ export const loadWorkPeriodAfterPaymentCancel = break; } } - dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); makeToast( `Payment ${amount} for ${userHandle} was marked as "cancelled"`, "success" ); - } else if (errorMessage) { - dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); - makeToast( - `Failed to load data for working period ${periodId}.\n` + errorMessage - ); + } else if (error) { + makeToast("Failed to reload working period data. " + error); } }; @@ -238,6 +248,72 @@ 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 + * @param {number} [periodDataDelay] timeout after which the period data gets + * reloaded + * @returns {function} + */ +export const updatePaymentsBillingAccount = + (periodId, billingAccountId, periodDataDelay = 3000) => + async (dispatch, getState) => { + let [periodsData] = selectors.getWorkPeriodsData(getState()); + let periodData = periodsData[periodId]; + if (!periodData) { + return; + } + 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; + } + await delay(periodDataDelay); + [periodData, errorMessage] = await dispatch(loadWorkPeriodData(periodId)); + if (errorMessage) { + makeToast("Failed to reload payments' data. " + errorMessage); + } else if (periodData) { + paymentsData = periodData.payments; + } + let paymentsNotUpdated = []; + for (let payment of paymentsData) { + if (payment.billingAccountId !== billingAccountId) { + paymentsNotUpdated.push(payment); + } + } + 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/misc.js b/src/utils/misc.js index ac471f8..2e913bf 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -139,6 +139,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, diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 8a4d091..d0022b9 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -214,22 +214,38 @@ 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 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(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) { From b9e544fa9f0e7feac66222e7faddc242cfd6abd8 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Wed, 11 Aug 2021 01:05:59 +0300 Subject: [PATCH 2/5] Made the working periods' table more compact. --- src/components/ActionsMenu/index.jsx | 7 ++++++- src/components/ActionsMenu/styles.module.scss | 7 ++++++- src/components/ProjectName/index.jsx | 6 +----- src/components/ProjectName/styles.module.scss | 3 --- .../components/PeriodAlerts/index.jsx | 4 +--- .../components/PeriodAlerts/styles.module.scss | 1 - .../components/PeriodItem/index.jsx | 18 +++++++++++++++--- .../components/PeriodItem/styles.module.scss | 15 +++++++++++++++ 8 files changed, 44 insertions(+), 17 deletions(-) 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/routes/WorkPeriods/components/PeriodAlerts/index.jsx b/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx index 896b77a..2a5732e 100644 --- a/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx @@ -41,9 +41,7 @@ const PeriodAlerts = ({ alerts, className }) => { )} 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..44d8263 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] @@ -184,8 +190,14 @@ 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; From bdbe5b9f6ff7846161dacf6b8246622e33c1f95a Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Wed, 11 Aug 2021 21:13:15 +0300 Subject: [PATCH 3/5] Added creation time column to payments' list. --- src/constants/workPeriods.js | 3 +++ .../components/PaymentsList/index.jsx | 1 + .../PaymentsList/styles.module.scss | 1 + .../components/PaymentsListItem/index.jsx | 10 ++++++++- .../PaymentsListItem/styles.module.scss | 4 ++++ src/utils/formatters.js | 22 +++++++++++++++++-- src/utils/misc.js | 1 - src/utils/workPeriods.js | 8 +++---- 8 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 59bf842..c7a1f2d 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -31,6 +31,9 @@ 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"; + +export const TIMEZONE_SOURCE = "America/New_York"; // Field names that are required to be retrieved for display, filtering and sorting. export const API_REQUIRED_FIELDS = [ 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..e57a0cb 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, + formatDateTimeInTimeZone, +} 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)} + + {formatDateTimeInTimeZone(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/utils/formatters.js b/src/utils/formatters.js index 5dca94a..f65cd21 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -1,6 +1,11 @@ -import moment from "moment"; +import moment from "moment-timezone"; 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, + TIMEZONE_SOURCE, +} from "constants/workPeriods"; import { PLATFORM_WEBSITE_URL, TAAS_BASE_PATH, @@ -29,6 +34,19 @@ export function formatDate(date) { return date ? moment(date).format(DATE_FORMAT_UI) : "-"; } +const TIMEZONE_BROWSER = moment.tz.guess(); + +/** + * Formats the date and time using the provided timezone. + * + * @param {*} dateTime value that can be parsed by Moment + * @param {string} [tz] timezone in which the resulting time will be displayed + * @returns {string} + */ +export function formatDateTimeInTimeZone(dateTime, tz = TIMEZONE_BROWSER) { + return moment.tz(dateTime, TIMEZONE_SOURCE).tz(tz).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 bed19e5..cc49429 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -253,4 +253,3 @@ export function validateAmount(value) { amount < 1e5 ); } - diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index d0022b9..0273a5e 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -1,12 +1,12 @@ -import moment from "moment"; +import moment from "moment-timezone"; import { ALERT, API_CHALLENGE_PAYMENT_STATUS_MAP, API_PAYMENT_STATUS_MAP, - DATE_FORMAT_API, DATE_FORMAT_ISO, PAYMENT_STATUS, REASON_DISABLED, + TIMEZONE_SOURCE, URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; @@ -127,7 +127,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" : "", @@ -230,7 +230,7 @@ export function normalizePeriodData(period) { export function normalizePeriodPayments(payments, data) { let lastFailedPayment = null; for (let payment of payments) { - payment.createdAt = moment(payment.createdAt).valueOf(); + payment.createdAt = moment.tz(payment.createdAt, TIMEZONE_SOURCE).valueOf(); payment.status = normalizeChallengePaymentStatus(payment.status); if (payment.status === PAYMENT_STATUS.FAILED) { lastFailedPayment = payment; From 4be052773575be10036254e2a8aaa4c29e9fa383 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Wed, 11 Aug 2021 23:49:10 +0300 Subject: [PATCH 4/5] Removed unnecessary timezone-handling code. --- src/constants/workPeriods.js | 2 -- .../components/PaymentsListItem/index.jsx | 4 ++-- src/utils/formatters.js | 14 +++++--------- src/utils/workPeriods.js | 5 ++--- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index c7a1f2d..1099616 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -33,8 +33,6 @@ 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"; -export const TIMEZONE_SOURCE = "America/New_York"; - // Field names that are required to be retrieved for display, filtering and sorting. export const API_REQUIRED_FIELDS = [ "id", diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx index e57a0cb..360b8eb 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -8,7 +8,7 @@ import PaymentStatus from "../PaymentStatus"; import { currencyFormatter, formatChallengeUrl, - formatDateTimeInTimeZone, + formatDateTimeAsLocal, } from "utils/formatters"; import { PAYMENT_STATUS } from "constants/workPeriods"; import styles from "./styles.module.scss"; @@ -60,7 +60,7 @@ const PaymentsListItem = ({ daysPaid, daysWorked, item }) => { {item.days} {currencyFormatter.format(item.amount)} - {formatDateTimeInTimeZone(item.createdAt)} + {formatDateTimeAsLocal(item.createdAt)}
diff --git a/src/utils/formatters.js b/src/utils/formatters.js index f65cd21..955cd91 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -1,10 +1,9 @@ -import moment from "moment-timezone"; +import moment from "moment"; import isNumber from "lodash/isNumber"; import { DATETIME_FORMAT_UI, DATE_FORMAT_UI, PAYMENT_STATUS_LABELS, - TIMEZONE_SOURCE, } from "constants/workPeriods"; import { PLATFORM_WEBSITE_URL, @@ -34,17 +33,14 @@ export function formatDate(date) { return date ? moment(date).format(DATE_FORMAT_UI) : "-"; } -const TIMEZONE_BROWSER = moment.tz.guess(); - /** - * Formats the date and time using the provided timezone. + * Formats the provided time in UTC-0 as time in local timezone. * - * @param {*} dateTime value that can be parsed by Moment - * @param {string} [tz] timezone in which the resulting time will be displayed + * @param {number} dateTime number of milliseconds since UTC epoch * @returns {string} */ -export function formatDateTimeInTimeZone(dateTime, tz = TIMEZONE_BROWSER) { - return moment.tz(dateTime, TIMEZONE_SOURCE).tz(tz).format(DATETIME_FORMAT_UI); +export function formatDateTimeAsLocal(dateTime) { + return moment(dateTime).format(DATETIME_FORMAT_UI); } /** diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 0273a5e..7263e0f 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -1,4 +1,4 @@ -import moment from "moment-timezone"; +import moment from "moment"; import { ALERT, API_CHALLENGE_PAYMENT_STATUS_MAP, @@ -6,7 +6,6 @@ import { DATE_FORMAT_ISO, PAYMENT_STATUS, REASON_DISABLED, - TIMEZONE_SOURCE, URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; @@ -230,7 +229,7 @@ export function normalizePeriodData(period) { export function normalizePeriodPayments(payments, data) { let lastFailedPayment = null; for (let payment of payments) { - payment.createdAt = moment.tz(payment.createdAt, TIMEZONE_SOURCE).valueOf(); + payment.createdAt = moment.utc(payment.createdAt).valueOf(); payment.status = normalizeChallengePaymentStatus(payment.status); if (payment.status === PAYMENT_STATUS.FAILED) { lastFailedPayment = payment; From fc388f9a84d7f4c62c4c442d6350af06d414a880 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Thu, 12 Aug 2021 17:38:52 +0300 Subject: [PATCH 5/5] Fixed tooltips for team names. Refactored payment cancelling. Added BA check on update. --- src/constants/workPeriods.js | 2 + .../components/PaymentModalCancel/index.jsx | 58 ++----- .../components/PaymentModalUpdateBA/index.jsx | 2 +- .../components/PeriodItem/index.jsx | 4 +- src/store/actionTypes/workPeriods.js | 1 + src/store/actions/workPeriods.js | 5 + src/store/reducers/workPeriods.js | 15 ++ src/store/thunks/workPeriods.js | 149 ++++++++++++------ src/utils/workPeriods.js | 12 ++ 9 files changed, 155 insertions(+), 93 deletions(-) diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 1099616..8243fc3 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -168,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 86b108e..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,14 +54,14 @@ const PaymentModalCancel = ({ payment, removeModal, timeout = 3000 }) => { approveText="Mark as cancelled" dismissText="Cancel cancelling" title={title} - isDisabled={isCancelPending} + 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. @@ -108,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 4971fa8..fd57aa4 100644 --- a/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx @@ -108,7 +108,7 @@ const PaymentModalUpdateBA = ({ payments = [], period, removeModal }) => { } (async function () { let ok = await dispatch( - updatePaymentsBillingAccount(period.id, billingAccountId, 5000) + updatePaymentsBillingAccount(period.id, billingAccountId) ); setIsModalOpen(!ok); setIsProcessing(false); diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 44d8263..ab7103b 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -177,6 +177,7 @@ const PeriodItem = ({ ({ 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 3199075..fc5f51d 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -4,12 +4,13 @@ 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, @@ -21,6 +22,7 @@ import { makeUrlQuery, normalizeBillingAccounts, normalizeDetailsPeriodItems, + normalizePaymentData, normalizePeriodData, normalizePeriodItems, } from "utils/workPeriods"; @@ -34,37 +36,38 @@ import { import { RESOURCE_BOOKING_STATUS, WORK_PERIODS_PATH } from "constants/index.js"; import { currencyFormatter } from "utils/formatters"; -export const loadWorkPeriodData = (periodId) => async (dispatch, getState) => { - let [periodsData] = selectors.getWorkPeriodsData(getState()); - periodsData[periodId]?.cancelSource?.cancel(); - const [promise, source] = services.fetchWorkPeriod(periodId); - dispatch(actions.setWorkPeriodDataPending(periodId, source)); - let userHandle = null; - let periodData = null; - let errorMessage = null; - try { - const data = await promise; - userHandle = data.userHandle; - periodData = normalizePeriodData(data); - } catch (error) { - if (!axios.isCancel(error)) { +/** + * 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 (periodData) { - dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); - return [{ ...periodData, userHandle }, null]; - } else if (errorMessage) { - dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); - return [null, errorMessage]; - } - return [null, null]; -}; - -export const loadWorkPeriodAfterPaymentCancel = - (periodId, paymentId) => async (dispatch) => { - let [periodData, error] = await dispatch(loadWorkPeriodData(periodId)); - if (periodData) { + 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) { @@ -77,9 +80,47 @@ export const loadWorkPeriodAfterPaymentCancel = `Payment ${amount} for ${userHandle} was marked as "cancelled"`, "success" ); - } else if (error) { - makeToast("Failed to reload working period data. " + error); } + 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 userHandle = null; + let periodData = null; + let errorMessage = null; + try { + const data = await promise; + userHandle = data.userHandle; + periodData = normalizePeriodData(data); + } catch (error) { + if (!axios.isCancel(error)) { + errorMessage = error.toString(); + } + } + if (periodData) { + dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); + return [{ ...periodData, userHandle }, null]; + } else if (errorMessage) { + dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); + return [null, errorMessage]; + } + return [null, null]; }; /** @@ -254,17 +295,14 @@ export const toggleWorkPeriodDetails = * * @param {string} periodId working period id * @param {number} billingAccountId desired billing account id - * @param {number} [periodDataDelay] timeout after which the period data gets - * reloaded * @returns {function} */ export const updatePaymentsBillingAccount = - (periodId, billingAccountId, periodDataDelay = 3000) => - async (dispatch, getState) => { + (periodId, billingAccountId) => async (dispatch, getState) => { let [periodsData] = selectors.getWorkPeriodsData(getState()); let periodData = periodsData[periodId]; if (!periodData) { - return; + return true; // no period to update } let paymentsToUpdate = []; for (let payment of periodData.payments) { @@ -290,18 +328,35 @@ export const updatePaymentsBillingAccount = makeToast(errorMessage); return false; } - await delay(periodDataDelay); - [periodData, errorMessage] = await dispatch(loadWorkPeriodData(periodId)); - if (errorMessage) { - makeToast("Failed to reload payments' data. " + errorMessage); - } else if (periodData) { - paymentsData = periodData.payments; - } let paymentsNotUpdated = []; + let paymentsUpdated = new Map(); for (let payment of paymentsData) { - if (payment.billingAccountId !== billingAccountId) { + 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."); diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 7263e0f..7743cbd 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -219,6 +219,18 @@ export function normalizePeriodData(period) { 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. *