- {!isDisabled && isTooltipShown && (
+ {!isDisabled && isTooltipShown && !!content && (
(
+
{children}
+);
+
+ValidationError.propTypes = {
+ children: PT.node.isRequired,
+ className: PT.string,
+};
+
+export default ValidationError;
diff --git a/src/components/ValidationError/styles.module.scss b/src/components/ValidationError/styles.module.scss
new file mode 100644
index 0000000..da60411
--- /dev/null
+++ b/src/components/ValidationError/styles.module.scss
@@ -0,0 +1,9 @@
+.container {
+ margin: 10px 0;
+ border: 1px solid #ffd5d1;
+ padding: 9px 10px;
+ min-height: 40px;
+ line-height: 22px;
+ font-size: 14px;
+ color: #ff5b52;
+}
diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js
index adc5e14..59bf842 100644
--- a/src/constants/workPeriods.js
+++ b/src/constants/workPeriods.js
@@ -53,6 +53,7 @@ export const API_REQUIRED_FIELDS = [
"workPeriods.daysWorked",
"workPeriods.daysPaid",
"workPeriods.payments.amount",
+ "workPeriods.payments.billingAccountId",
"workPeriods.payments.challengeId",
"workPeriods.payments.createdAt",
"workPeriods.payments.days",
@@ -126,6 +127,12 @@ export const API_CHALLENGE_PAYMENT_STATUS_MAP = {
[API_CHALLENGE_PAYMENT_STATUS.SCHEDULED]: PAYMENT_STATUS.SCHEDULED,
};
+export const CHALLENGE_PAYMENT_ACTIVE_STATUSES = {
+ [PAYMENT_STATUS.COMPLETED]: PAYMENT_STATUS.COMPLETED,
+ [PAYMENT_STATUS.IN_PROGRESS]: PAYMENT_STATUS.IN_PROGRESS,
+ [PAYMENT_STATUS.SCHEDULED]: PAYMENT_STATUS.SCHEDULED,
+};
+
export const URL_QUERY_PARAM_MAP = new Map([
["startDate", "startDate"],
["paymentStatuses", "status"],
@@ -159,3 +166,5 @@ export const ALERT_MESSAGE_MAP = {
[ALERT.BA_NOT_ASSIGNED]: "BA - Not Assigned",
[ALERT.LAST_BOOKING_WEEK]: "Last Booking Week",
};
+
+export const DAYS_WORKED_HARD_LIMIT = 10;
diff --git a/src/routes/WorkPeriods/components/PaymentActions/index.jsx b/src/routes/WorkPeriods/components/PaymentActions/index.jsx
new file mode 100644
index 0000000..38742f0
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentActions/index.jsx
@@ -0,0 +1,87 @@
+import React, { useCallback, useMemo, useState } from "react";
+import PT from "prop-types";
+import cn from "classnames";
+import ActionsMenu from "components/ActionsMenu";
+import PaymentModalCancel from "../PaymentModalCancel";
+import PaymentModalEdit from "../PaymentModalEdit";
+import PaymentModalEditAdditional from "../PaymentModalEditAdditional";
+import { PAYMENT_STATUS } from "constants/workPeriods";
+import styles from "./styles.module.scss";
+
+/**
+ * Displays a menu with actions for specific payment.
+ *
+ * @param {Object} props component properties
+ * @returns {JSX.Element}
+ */
+const PaymentActions = ({ className, daysPaid, daysWorked, payment }) => {
+ const [isOpenCancelModal, setIsOpenCancelModal] = useState(false);
+ const [isOpenEditModal, setIsOpenEditModal] = useState(false);
+ const paymentStatus = payment.status;
+
+ const closeCancelModal = useCallback(() => {
+ setIsOpenCancelModal(false);
+ }, []);
+
+ const closeEditModal = useCallback(() => {
+ setIsOpenEditModal(false);
+ }, []);
+
+ const actions = useMemo(
+ () => [
+ {
+ label: "Edit Payment",
+ action() {
+ setIsOpenEditModal(true);
+ },
+ disabled: paymentStatus === PAYMENT_STATUS.IN_PROGRESS,
+ },
+ {
+ label: "Cancel Payment",
+ action() {
+ setIsOpenCancelModal(true);
+ },
+ disabled:
+ paymentStatus === PAYMENT_STATUS.CANCELLED ||
+ paymentStatus === PAYMENT_STATUS.IN_PROGRESS,
+ },
+ ],
+ [paymentStatus]
+ );
+
+ return (
+
+
+ {isOpenCancelModal && (
+
+ )}
+ {isOpenEditModal &&
+ (payment.days > 0 ? (
+
+ ) : (
+
+ ))}
+
+ );
+};
+
+PaymentActions.propTypes = {
+ className: PT.string,
+ daysPaid: PT.number.isRequired,
+ daysWorked: PT.number.isRequired,
+ payment: PT.shape({
+ days: PT.number.isRequired,
+ id: PT.string.isRequired,
+ status: PT.string.isRequired,
+ }).isRequired,
+};
+
+export default PaymentActions;
diff --git a/src/routes/WorkPeriods/components/PaymentActions/styles.module.scss b/src/routes/WorkPeriods/components/PaymentActions/styles.module.scss
new file mode 100644
index 0000000..04fc133
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentActions/styles.module.scss
@@ -0,0 +1,3 @@
+.container {
+ display: inline-flex;
+}
diff --git a/src/routes/WorkPeriods/components/PaymentModalAdditional/index.jsx b/src/routes/WorkPeriods/components/PaymentModalAdditional/index.jsx
new file mode 100644
index 0000000..0838110
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalAdditional/index.jsx
@@ -0,0 +1,131 @@
+import React, { useCallback, useEffect, useState } from "react";
+import PT from "prop-types";
+import moment from "moment";
+import debounce from "lodash/debounce";
+import Modal from "components/Modal";
+import Spinner from "components/Spinner";
+import TextField from "components/TextField";
+import ValidationError from "components/ValidationError";
+import { makeToast } from "components/ToastrMessage";
+import { postWorkPeriodPayment } from "services/workPeriods";
+import { useUpdateEffect } from "utils/hooks";
+import { preventDefault, validateAmount } from "utils/misc";
+import styles from "./styles.module.scss";
+
+/**
+ * Displays a modal which allows to schedule arbitrary payment for specific
+ * working period.
+ *
+ * @param {Object} props component properties
+ * @returns {JSX.Element}
+ */
+const PaymentModalAdditional = ({ period, removeModal }) => {
+ const [isModalOpen, setIsModalOpen] = useState(true);
+ const [amount, setAmount] = useState("0");
+ const [isAmountValid, setIsAmountValid] = useState(true);
+ const [isProcessing, setIsProcessing] = useState(false);
+
+ const onApprove = () => {
+ let isAmountValid = validateAmount(amount);
+ if (isAmountValid) {
+ setIsProcessing(true);
+ }
+ setIsAmountValid(isAmountValid);
+ };
+
+ const onDismiss = useCallback(() => {
+ setIsModalOpen(false);
+ }, []);
+
+ const onChangeAmount = useCallback((amount) => {
+ setAmount((amount || "").trim());
+ }, []);
+
+ const validateAmountDebounced = useCallback(
+ debounce(
+ (amount) => {
+ setIsAmountValid(validateAmount(amount));
+ },
+ 500,
+ { leading: false }
+ ),
+ []
+ );
+
+ useUpdateEffect(() => {
+ setIsAmountValid(true);
+ validateAmountDebounced(amount);
+ }, [amount]);
+
+ useEffect(() => {
+ if (!isProcessing) {
+ return;
+ }
+ postWorkPeriodPayment({ workPeriodId: period.id, days: 0, amount })
+ .then(() => {
+ makeToast("Additional payment scheduled for resource", "success");
+ setIsModalOpen(false);
+ })
+ .catch((error) => {
+ makeToast(error.toString());
+ })
+ .finally(() => {
+ setIsProcessing(false);
+ });
+ }, [amount, isProcessing, period.id]);
+
+ return (
+
+ {isProcessing ? (
+
+ ) : (
+ <>
+
+ Additional payment for Resource Booking "{period.userHandle}
+ " for week "{moment(period.start).format("MM/DD")}
+ - {moment(period.end).format("MM/DD")}"
+
+
+ >
+ )}
+
+ );
+};
+
+PaymentModalAdditional.propTypes = {
+ period: PT.shape({
+ id: PT.string.isRequired,
+ userHandle: PT.string.isRequired,
+ start: PT.oneOfType([PT.number, PT.string]).isRequired,
+ end: PT.oneOfType([PT.number, PT.string]).isRequired,
+ }).isRequired,
+ removeModal: PT.func.isRequired,
+};
+
+export default PaymentModalAdditional;
diff --git a/src/routes/WorkPeriods/components/PaymentModalAdditional/styles.module.scss b/src/routes/WorkPeriods/components/PaymentModalAdditional/styles.module.scss
new file mode 100644
index 0000000..444c8d9
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalAdditional/styles.module.scss
@@ -0,0 +1,12 @@
+@import "styles/mixins";
+
+.form {
+ margin-top: 20px;
+}
+
+.amountField,
+.amountError {
+ @include desktop {
+ width: 75%;
+ }
+}
diff --git a/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx b/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx
new file mode 100644
index 0000000..8cc1a52
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalCancel/index.jsx
@@ -0,0 +1,113 @@
+import React, { useCallback, useEffect, useState } from "react";
+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";
+
+/**
+ * Displays a Cancel button. Shows a modal with payment cancelling confirmation
+ * when clicking this button.
+ *
+ * @param {Object} props component properties
+ * @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 [isModalOpen, setIsModalOpen] = useState(true);
+ const [isCancelPending, setIsCancelPending] = useState(false);
+ const [isCancelSuccess, setIsCancelSuccess] = useState(false);
+ const dispatch = useDispatch();
+ const { id: paymentId, workPeriodId: periodId } = payment;
+
+ const onApprove = useCallback(() => {
+ setIsCancelPending(true);
+ }, []);
+
+ const onDismiss = useCallback(() => {
+ setIsModalOpen(false);
+ }, []);
+
+ useEffect(() => {
+ if (!isCancelPending) {
+ 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]);
+
+ let title, controls;
+ if (isCancelPending) {
+ controls = null;
+ title = "Marking as cancelled...";
+ } else {
+ controls = undefined;
+ title = "Warning!";
+ }
+
+ return (
+
+ {isCancelPending ? (
+
+ ) : (
+ `Cancelling payment here will only mark it as cancelled in TaaS system.
+ Before cancelling it here, make sure that actual payment is cancelled in
+ PACTS first, and only after that you may mark it as cancelled here.`
+ )}
+
+ );
+};
+
+PaymentModalCancel.propTypes = {
+ payment: PT.shape({
+ id: PT.string.isRequired,
+ status: PT.string.isRequired,
+ workPeriodId: PT.string.isRequired,
+ }).isRequired,
+ removeModal: PT.func.isRequired,
+ timeout: PT.number,
+};
+
+export default PaymentModalCancel;
diff --git a/src/routes/WorkPeriods/components/PaymentModalCancel/styles.module.scss b/src/routes/WorkPeriods/components/PaymentModalCancel/styles.module.scss
new file mode 100644
index 0000000..7b5a0a2
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalCancel/styles.module.scss
@@ -0,0 +1,3 @@
+.container {
+ display: inline-block;
+}
diff --git a/src/routes/WorkPeriods/components/PaymentModalEdit/index.jsx b/src/routes/WorkPeriods/components/PaymentModalEdit/index.jsx
new file mode 100644
index 0000000..6c93731
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalEdit/index.jsx
@@ -0,0 +1,138 @@
+import React, { useCallback, useEffect, useState } from "react";
+import PT from "prop-types";
+import IntegerFieldHinted from "components/IntegerFieldHinted";
+import Modal from "components/Modal";
+import Spinner from "components/Spinner";
+import TextField from "components/TextField";
+import { makeToast } from "components/ToastrMessage";
+import { patchWorkPeriodPayment } from "services/workPeriods";
+import { currencyFormatter } from "utils/formatters";
+import { preventDefault } from "utils/misc";
+import { CHALLENGE_PAYMENT_ACTIVE_STATUSES } from "constants/workPeriods";
+import styles from "./styles.module.scss";
+
+/**
+ * Displays a modal that allows to edit specific payment using member's weekly
+ * payment rate and the number of days.
+ *
+ * @param {Object} props component properties
+ * @returns {JSX.Element}
+ */
+const PaymentModalEdit = ({ daysPaid, daysWorked, payment, removeModal }) => {
+ const [isModalOpen, setIsModalOpen] = useState(true);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [days, setDays] = useState(payment.days);
+
+ const maxDays =
+ daysWorked -
+ daysPaid +
+ (payment.status in CHALLENGE_PAYMENT_ACTIVE_STATUSES ? payment.days : 0);
+
+ const weeklyRate = payment.memberRate;
+ const dailyRate = weeklyRate / 5;
+
+ const amount = ((days * weeklyRate) / 5).toFixed(2);
+
+ const onApprove = useCallback(() => {
+ setIsProcessing(true);
+ }, []);
+
+ const onDismiss = useCallback(() => {
+ setIsModalOpen(false);
+ }, []);
+
+ useEffect(() => {
+ if (!isProcessing) {
+ return;
+ }
+ patchWorkPeriodPayment(payment.id, { amount, days })
+ .then(() => {
+ makeToast("Payment was successfully updated", "success");
+ setIsModalOpen(false);
+ })
+ .catch((error) => {
+ makeToast(error.toString());
+ })
+ .finally(() => {
+ setIsProcessing(false);
+ });
+ }, [amount, days, isProcessing, payment.id]);
+
+ return (
+
+ {isProcessing ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+PaymentModalEdit.propTypes = {
+ daysPaid: PT.number.isRequired,
+ daysWorked: PT.number.isRequired,
+ payment: PT.shape({
+ amount: PT.number.isRequired,
+ days: PT.number.isRequired,
+ id: PT.string.isRequired,
+ memberRate: PT.number.isRequired,
+ status: PT.string.isRequired,
+ }),
+ removeModal: PT.func.isRequired,
+};
+
+export default PaymentModalEdit;
diff --git a/src/routes/WorkPeriods/components/PaymentModalEdit/styles.module.scss b/src/routes/WorkPeriods/components/PaymentModalEdit/styles.module.scss
new file mode 100644
index 0000000..838f9bf
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalEdit/styles.module.scss
@@ -0,0 +1,38 @@
+@import "styles/variables";
+@import "styles/mixins";
+
+.form {
+ margin: 20px 0 0;
+
+ table {
+ margin: 0 -6px;
+ width: 100%;
+ table-layout: auto;
+
+ @include desktop {
+ width: 75%;
+ margin-right: auto;
+ }
+ }
+
+ th,
+ td {
+ padding: 5px 6px;
+ white-space: nowrap;
+ }
+
+ th {
+ font-weight: bold;
+ }
+}
+
+.rates {
+ display: block;
+ height: 30px;
+ line-height: 30px;
+}
+
+.notice {
+ margin: 12px 0;
+ color: $warning-color;
+}
diff --git a/src/routes/WorkPeriods/components/PaymentModalEditAdditional/index.jsx b/src/routes/WorkPeriods/components/PaymentModalEditAdditional/index.jsx
new file mode 100644
index 0000000..dca47d6
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalEditAdditional/index.jsx
@@ -0,0 +1,131 @@
+import React, { useCallback, useEffect, useState } from "react";
+import PT from "prop-types";
+import debounce from "lodash/debounce";
+import Modal from "components/Modal";
+import Spinner from "components/Spinner";
+import TextField from "components/TextField";
+import ValidationError from "components/ValidationError";
+import { makeToast } from "components/ToastrMessage";
+import { patchWorkPeriodPayment } from "services/workPeriods";
+import { useUpdateEffect } from "utils/hooks";
+import { preventDefault, validateAmount } from "utils/misc";
+import styles from "./styles.module.scss";
+
+/**
+ * Displays a modal allowing to edit additional payment amount.
+ *
+ * @param {Object} props component properties
+ * @returns {JSX.Element}
+ */
+const PaymentModalEditAdditional = ({ payment, removeModal }) => {
+ const [isModalOpen, setIsModalOpen] = useState(true);
+ const [amount, setAmount] = useState(payment.amount);
+ const [isAmountValid, setIsAmountValid] = useState(true);
+ const [isProcessing, setIsProcessing] = useState(false);
+
+ const amountControlId = `edit_pmt_amt_${payment.id}`;
+
+ const onApprove = () => {
+ let isAmountValid = validateAmount(amount);
+ if (isAmountValid) {
+ setIsProcessing(true);
+ }
+ setIsAmountValid(isAmountValid);
+ };
+
+ const onDismiss = useCallback(() => {
+ setIsModalOpen(false);
+ }, []);
+
+ const onAmountChange = useCallback((amount) => {
+ setAmount((amount || "").trim());
+ }, []);
+
+ const validateAmountDebounced = useCallback(
+ debounce(
+ (amount) => {
+ setIsAmountValid(validateAmount(amount));
+ },
+ 500,
+ { leading: false }
+ ),
+ []
+ );
+
+ useUpdateEffect(() => {
+ setIsAmountValid(true);
+ validateAmountDebounced(amount);
+ }, [amount]);
+
+ useEffect(() => {
+ if (!isProcessing) {
+ return;
+ }
+ patchWorkPeriodPayment(payment.id, { amount })
+ .then(() => {
+ makeToast("Payment was successfully updated", "success");
+ setIsModalOpen(false);
+ })
+ .catch((error) => {
+ makeToast(error.toString());
+ })
+ .finally(() => {
+ setIsProcessing(false);
+ });
+ }, [amount, isProcessing, payment.id]);
+
+ return (
+
+ {isProcessing ? (
+
+ ) : (
+ <>
+
+
+ Notice: please, update payment amount in PACTS too.
+
+ >
+ )}
+
+ );
+};
+
+PaymentModalEditAdditional.propTypes = {
+ payment: PT.shape({
+ amount: PT.number.isRequired,
+ id: PT.string.isRequired,
+ }).isRequired,
+ removeModal: PT.func.isRequired,
+};
+
+export default PaymentModalEditAdditional;
diff --git a/src/routes/WorkPeriods/components/PaymentModalEditAdditional/styles.module.scss b/src/routes/WorkPeriods/components/PaymentModalEditAdditional/styles.module.scss
new file mode 100644
index 0000000..6407f45
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalEditAdditional/styles.module.scss
@@ -0,0 +1,32 @@
+@import "styles/variables";
+@import "styles/mixins";
+
+.fieldRow {
+ display: flex;
+ align-items: baseline;
+}
+
+.fieldLabel {
+ flex: 0 0 auto;
+ padding: 0 20px 0 0;
+
+ label {
+ font-weight: bold;
+ }
+}
+
+.fieldControl {
+ flex: 1 0 auto;
+ display: flex;
+ flex-direction: column;
+
+ @include desktop {
+ flex: 0 0 auto;
+ width: 50%;
+ }
+}
+
+.notice {
+ margin: 12px 0;
+ color: $warning-color;
+}
diff --git a/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx b/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx
new file mode 100644
index 0000000..14262b9
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalUpdateBA/index.jsx
@@ -0,0 +1,230 @@
+import React, { useCallback, useEffect, useState } from "react";
+import PT from "prop-types";
+import cn from "classnames";
+import moment from "moment";
+import JobName from "components/JobName";
+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 {
+ createAssignedBillingAccountOption,
+ normalizeBillingAccounts,
+} from "utils/workPeriods";
+import { preventDefault } from "utils/misc";
+import {
+ BILLING_ACCOUNTS_ERROR,
+ BILLING_ACCOUNTS_LOADING,
+ BILLING_ACCOUNTS_NONE,
+} from "constants/workPeriods";
+import styles from "./styles.module.scss";
+
+/**
+ * Displays a modal that allows to update billing account for all working period
+ * payments.
+ *
+ * @param {Object} props component properties
+ * @returns {JSX.Element}
+ */
+const PaymentModalUpdateBA = ({ payments = [], period, removeModal }) => {
+ const [isModalOpen, setIsModalOpen] = useState(true);
+ const [billingAccountId, setBillingAccountId] = useState(
+ period.billingAccountId
+ );
+ const [billingAccounts, setBillingAccounts] = useState([
+ { label: BILLING_ACCOUNTS_LOADING, value: billingAccountId },
+ ]);
+ const [billingAccountsDisabled, setBillingAccountsDisabled] = useState(true);
+ const [billingAccountsError, setBillingAccountsError] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+
+ useEffect(() => {
+ const [bilAccsPromise] = fetchBillingAccounts(period.projectId);
+ bilAccsPromise
+ .then((data) => {
+ const accounts = normalizeBillingAccounts(data);
+ let accountId = period.billingAccountId;
+ let hasAssignedAccount = false;
+ for (let account of accounts) {
+ if (account.value === accountId) {
+ hasAssignedAccount = true;
+ break;
+ }
+ }
+ if (accountId > 0 && !hasAssignedAccount) {
+ accounts.unshift(createAssignedBillingAccountOption(accountId));
+ }
+ let accountsDisabled = false;
+ if (!accounts.length) {
+ accounts.push({
+ value: accountId,
+ label: BILLING_ACCOUNTS_NONE,
+ });
+ accountsDisabled = true;
+ }
+ setBillingAccountsDisabled(accountsDisabled);
+ setBillingAccounts(accounts);
+ })
+ .catch((error) => {
+ let accounts = [];
+ let accountsDisabled = true;
+ let accountId = period.billingAccountId;
+ if (accountId) {
+ accounts.push(createAssignedBillingAccountOption(accountId));
+ accountsDisabled = false;
+ } else {
+ accounts.push({
+ value: accountId,
+ label: BILLING_ACCOUNTS_ERROR,
+ });
+ }
+ setBillingAccountsDisabled(accountsDisabled);
+ setBillingAccountsError(error.toString());
+ setBillingAccounts(accounts);
+ });
+ }, [period.billingAccountId, period.projectId]);
+
+ useEffect(() => {
+ 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;
+ }
+
+ return (
+
+ {isProcessing ? (
+
+ ) : (
+ <>
+
+ Update Billing account for all the payments done during the Work
+ Period.
+
+
+
+
+
+ Work Period: |
+
+ {moment(period.start).format("MM/DD")} -
+ {moment(period.end).format("MM/DD")}
+ |
+
+
+ Resource Booking: |
+ {period.userHandle} |
+
+
+ Job Name: |
+
+
+ |
+
+
+ Team Name: |
+
+
+ |
+
+
+ Current BA(s) used: |
+
+ {Object.keys(accountIdsHash).join(", ") || "-"}
+ |
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+PaymentModalUpdateBA.propTypes = {
+ payments: PT.arrayOf(
+ PT.shape({
+ id: PT.oneOfType([PT.number, PT.string]).isRequired,
+ billingAccountId: PT.number.isRequired,
+ })
+ ).isRequired,
+ period: PT.shape({
+ id: PT.string.isRequired,
+ jobId: PT.oneOfType([PT.number, PT.string]),
+ projectId: PT.oneOfType([PT.number, PT.string]).isRequired,
+ billingAccountId: PT.number,
+ userHandle: PT.string.isRequired,
+ start: PT.oneOfType([PT.number, PT.string]).isRequired,
+ end: PT.oneOfType([PT.number, PT.string]).isRequired,
+ }).isRequired,
+ removeModal: PT.func.isRequired,
+};
+
+export default PaymentModalUpdateBA;
diff --git a/src/routes/WorkPeriods/components/PaymentModalUpdateBA/styles.module.scss b/src/routes/WorkPeriods/components/PaymentModalUpdateBA/styles.module.scss
new file mode 100644
index 0000000..ae3660a
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PaymentModalUpdateBA/styles.module.scss
@@ -0,0 +1,68 @@
+@import "styles/variables";
+@import "styles/mixins";
+
+.modal {
+ overflow: visible;
+}
+
+.periodInfo {
+ margin: 20px 0 0;
+
+ table {
+ margin: 0 -6px;
+ width: 100%;
+ table-layout: auto;
+ }
+
+ th,
+ td {
+ padding: 5px 6px;
+ white-space: nowrap;
+ }
+
+ th {
+ font-weight: bold;
+ }
+
+ td {
+ width: 100%;
+ }
+}
+
+.jobName,
+.teamName {
+ display: inline;
+ font-weight: normal;
+}
+
+.accountIds {
+ white-space: normal;
+}
+
+.form {
+ margin-top: 20px;
+}
+
+.accountsSelect {
+ display: flex;
+ flex-direction: column;
+
+ @include desktop {
+ width: 75%;
+ }
+
+ &.accountsError {
+ color: #e90c5a;
+ }
+
+ .accountsSelectLabel {
+ position: static;
+ align-self: flex-start;
+ margin: 0 0 5px;
+ padding: 0;
+ font-size: 16px;
+ font-weight: bold;
+ color: $text-color;
+ transform: none;
+ }
+}
diff --git a/src/routes/WorkPeriods/components/PaymentTotal/index.jsx b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx
index 3dc104d..963391f 100644
--- a/src/routes/WorkPeriods/components/PaymentTotal/index.jsx
+++ b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx
@@ -14,6 +14,7 @@ import styles from "./styles.module.scss";
* @param {Array} [props.payments] an array with payments information
* @param {number} props.paymentTotal total paid sum
* @param {number} props.daysPaid number of paid days
+ * @param {number} props.daysWorked number of days the user already worked
* @param {'absolute'|'fixed'} [props.popupStrategy] popup positioning strategy
* @returns {JSX.Element}
*/
@@ -22,13 +23,21 @@ const PaymentTotal = ({
payments,
paymentTotal,
daysPaid,
+ daysWorked,
popupStrategy = "absolute",
}) => {
const hasPayments = !!payments && !!payments.length;
const paymentsList = useMemo(
- () => (hasPayments ?
: null),
- [hasPayments, payments]
+ () =>
+ hasPayments ? (
+
+ ) : null,
+ [hasPayments, daysPaid, daysWorked, payments]
);
return (
@@ -55,6 +64,7 @@ PaymentTotal.propTypes = {
payments: PT.array,
paymentTotal: PT.number.isRequired,
daysPaid: PT.number.isRequired,
+ daysWorked: PT.number.isRequired,
popupStrategy: PT.oneOf(["absolute", "fixed"]),
};
diff --git a/src/routes/WorkPeriods/components/PaymentsList/index.jsx b/src/routes/WorkPeriods/components/PaymentsList/index.jsx
index 580a9eb..bf9f9d7 100644
--- a/src/routes/WorkPeriods/components/PaymentsList/index.jsx
+++ b/src/routes/WorkPeriods/components/PaymentsList/index.jsx
@@ -10,7 +10,7 @@ import PaymentsListItem from "../PaymentsListItem";
* @param {Object} props component properties
* @returns {JSX.Element}
*/
-const PaymentsList = ({ className, payments }) => (
+const PaymentsList = ({ className, daysPaid, daysWorked, payments }) => (
);
};
PaymentsListItem.propTypes = {
+ daysPaid: PT.number.isRequired,
+ daysWorked: PT.number.isRequired,
item: PT.shape({
id: PT.oneOfType([PT.string, PT.number]).isRequired,
amount: PT.number.isRequired,
diff --git a/src/routes/WorkPeriods/components/PeriodActions/index.jsx b/src/routes/WorkPeriods/components/PeriodActions/index.jsx
new file mode 100644
index 0000000..2de5b69
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PeriodActions/index.jsx
@@ -0,0 +1,85 @@
+import React, { useCallback, useMemo, useState } from "react";
+import PT from "prop-types";
+import cn from "classnames";
+import ActionsMenu from "components/ActionsMenu";
+import PaymentModalAdditional from "../PaymentModalAdditional";
+import PaymentModalUpdateBA from "../PaymentModalUpdateBA";
+import styles from "./styles.module.scss";
+
+/**
+ * Displays period actions' dropdown menu.
+ *
+ * @param {Object} props component properties
+ * @returns {JSX.Element}
+ */
+const PeriodActions = ({ className, period, periodData }) => {
+ const [isAddPaymentModalOpen, setIsAddPaymentModalOpen] = useState(false);
+ const [isUpdateBAModalOpen, setIsUpdateBAModalOpen] = useState(false);
+ const payments = periodData.payments;
+
+ const openAddPaymentModal = useCallback(() => {
+ setIsAddPaymentModalOpen(true);
+ }, []);
+
+ const closeAddPaymentModal = useCallback(() => {
+ setIsAddPaymentModalOpen(false);
+ }, []);
+
+ const openUpdateBAModal = useCallback(() => {
+ setIsUpdateBAModalOpen(true);
+ }, []);
+
+ const closeUpdateBAModal = useCallback(() => {
+ setIsUpdateBAModalOpen(false);
+ }, []);
+
+ const actions = useMemo(() => {
+ let actions = [
+ { label: "Additional Payment", action: openAddPaymentModal },
+ ];
+ if (payments?.length) {
+ actions.push({
+ label: "Update BA for payments",
+ action: openUpdateBAModal,
+ });
+ }
+ return actions;
+ }, [payments, openAddPaymentModal, openUpdateBAModal]);
+
+ return (
+
+ );
+};
+
+PeriodActions.propTypes = {
+ className: PT.string,
+ period: PT.shape({
+ id: PT.string.isRequired,
+ start: PT.oneOfType([PT.number, PT.string]).isRequired,
+ end: PT.oneOfType([PT.number, PT.string]).isRequired,
+ }).isRequired,
+ periodData: PT.shape({
+ payments: PT.array,
+ }).isRequired,
+};
+
+export default PeriodActions;
diff --git a/src/routes/WorkPeriods/components/PeriodActions/styles.module.scss b/src/routes/WorkPeriods/components/PeriodActions/styles.module.scss
new file mode 100644
index 0000000..04fc133
--- /dev/null
+++ b/src/routes/WorkPeriods/components/PeriodActions/styles.module.scss
@@ -0,0 +1,3 @@
+.container {
+ display: inline-flex;
+}
diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx
index 457bd20..fd8b5e4 100644
--- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx
+++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx
@@ -89,7 +89,7 @@ const PeriodDetails = ({
)}
>
{periodsIsLoading ? (
-
) : (
@@ -122,7 +122,7 @@ const PeriodDetails = ({