From 912a3c4c52e49a07392e2db9efb332d1e0a71a18 Mon Sep 17 00:00:00 2001 From: Rakib Ansary Date: Mon, 18 Mar 2024 07:15:58 +0600 Subject: [PATCH 1/4] feat(admin-ui): edit flows with audit ui Signed-off-by: Rakib Ansary --- .../src/home/tabs/payments/PaymentsTab.tsx | 112 +++++++- .../wallet-admin/src/lib/components/index.ts | 1 + .../payment-edit/PaymentEdit.module.scss | 37 +++ .../components/payment-edit/PaymentEdit.tsx | 244 +++++++++++++++--- .../payment-view/PaymentView.module.scss | 42 +++ .../components/payment-view/PaymentView.tsx | 124 +++++++++ .../src/lib/components/payment-view/index.ts | 1 + .../payments-table/PaymentTable.module.scss | 7 + .../payments-table/PaymentTable.tsx | 9 +- .../src/lib/models/ConfirmFlowData.ts | 1 + .../src/lib/models/WinningDetail.ts | 2 + .../wallet-admin/src/lib/services/wallet.ts | 20 +- .../form-input/input-text/InputText.tsx | 2 +- .../form-input/input-wrapper/InputWrapper.tsx | 2 +- .../modals/confirm/ConfirmModal.tsx | 37 +-- 15 files changed, 569 insertions(+), 72 deletions(-) create mode 100644 src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.module.scss create mode 100644 src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx create mode 100644 src/apps/wallet-admin/src/lib/components/payment-view/index.ts diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsTab.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsTab.tsx index d730a333e..86535b88e 100644 --- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsTab.tsx +++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsTab.tsx @@ -1,13 +1,14 @@ /* eslint-disable max-len */ /* eslint-disable react/jsx-no-bind */ +import { toast } from 'react-toastify' import React, { FC, useCallback, useEffect } from 'react' import { Collapsible, ConfirmModal, LoadingCircles } from '~/libs/ui' import { UserProfile } from '~/libs/core' -import { getMemberHandle, getPayments } from '../../../lib/services/wallet' +import { editPayment, getMemberHandle, getPayments } from '../../../lib/services/wallet' import { Winning, WinningDetail } from '../../../lib/models/WinningDetail' -import { FilterBar } from '../../../lib' +import { FilterBar, PaymentView } from '../../../lib' import { ConfirmFlowData } from '../../../lib/models/ConfirmFlowData' import { PaginationInfo } from '../../../lib/models/PaginationInfo' import PaymentEditForm from '../../../lib/components/payment-edit/PaymentEdit' @@ -84,6 +85,30 @@ const ListView: FC = (props: ListViewProps) => { totalItems: 0, totalPages: 0, }) + const [editState, setEditState] = React.useState<{ + netAmount?: number; + releaseDate?: Date; + paymentStatus?: string; + auditNote?: string; + }>({}) + + const editStateRef = React.useRef(editState) + + useEffect(() => { + editStateRef.current = editState + }, [editState]) + + const handleValueUpdated = useCallback((updates: { + auditNote?: string, + netAmount?: number, + paymentStatus?: string, + releaseDate?: Date, + }) => { + setEditState(prev => ({ + ...prev, + ...updates, + })) + }, []) const convertToWinnings = useCallback( (payments: WinningDetail[], handleMap: Map) => payments.map(payment => { @@ -116,7 +141,9 @@ const ListView: FC = (props: ListViewProps) => { handle: handleMap.get(parseInt(payment.winnerId, 10)) ?? payment.winnerId, id: payment.id, netPayment: formatCurrency(payment.details[0].totalAmount, payment.details[0].currency), + netPaymentNumber: parseFloat(payment.details[0].totalAmount), releaseDate: formattedReleaseDate, + releaseDateObj: releaseDate, status: formatStatus(payment.details[0].status), type: payment.category.replaceAll('_', ' ') .toLowerCase(), @@ -143,6 +170,7 @@ const ListView: FC = (props: ListViewProps) => { } finally { setIsLoading(false) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [convertToWinnings, filters, pagination.currentPage, pagination.pageSize]) const renderConfirmModalContent = React.useMemo(() => { @@ -157,10 +185,70 @@ const ListView: FC = (props: ListViewProps) => { return confirmFlow?.content }, [confirmFlow]) + const updatePayment = async (paymentId: string): Promise => { + const currentEditState = editStateRef.current + // Send to server only the fields that have changed + const updateObj = { + auditNote: currentEditState.auditNote !== undefined ? currentEditState.auditNote : undefined, + netAmount: currentEditState.netAmount !== undefined ? currentEditState.netAmount : undefined, + paymentStatus: currentEditState.paymentStatus !== undefined ? currentEditState.paymentStatus : undefined, + releaseDate: currentEditState.releaseDate !== undefined ? currentEditState.releaseDate : undefined, + } + + let paymentStatus : 'ON_HOLD_ADMIN' | 'OWED' | undefined + if (updateObj.paymentStatus !== undefined) paymentStatus = updateObj.paymentStatus.indexOf('Owed') > -1 ? 'OWED' : 'ON_HOLD_ADMIN' + + const updates: { + auditNote?: string + paymentStatus?: 'ON_HOLD_ADMIN' | 'OWED' + releaseDate?: string + paymentAmount?: number + winningsId: string + } = { + auditNote: updateObj.auditNote, + winningsId: paymentId, + } + + if (paymentStatus) updates.paymentStatus = paymentStatus + if (updateObj.releaseDate !== undefined) updates.releaseDate = updateObj.releaseDate.toISOString() + if (updateObj.netAmount !== undefined) updates.paymentAmount = updateObj.netAmount + + toast.success('Updating payment', { position: toast.POSITION.BOTTOM_RIGHT }) + try { + const udpateMessage = await editPayment(updates) + toast.success(udpateMessage, { position: toast.POSITION.BOTTOM_RIGHT }) + } catch (err) { + toast.error('Failed to update payment', { position: toast.POSITION.BOTTOM_RIGHT }) + return + } + + setEditState({}) + + await fetchWinnings() + } + useEffect(() => { fetchWinnings() }, [fetchWinnings]) + const onPaymentEditCallback = useCallback((payment: Winning) => { + setConfirmFlow({ + action: 'Save', + callback: async () => { + updatePayment(payment.id) + }, + content: ( + + ), + title: 'Edit Payment', + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleValueUpdated, editState]) + return ( <>
@@ -320,19 +408,23 @@ const ListView: FC = (props: ListViewProps) => { currentPage: pageNumber, }) }} - onPaymentEditClick={function onPaymentEditClicked(payment: Winning) { + onPaymentEditClick={(payment: Winning) => { + setEditState({}) + onPaymentEditCallback(payment) + }} + onPaymentViewClick={function onPaymentViewClicked(payment: Winning) { setConfirmFlow({ action: 'Save', - callback: () => console.log('Edit payment:', payment), + callback: async () => { + updatePayment(payment.id) + }, content: ( - ), - title: 'Edit Payment', + showButtons: false, + title: 'Payment Details', }) }} /> @@ -351,9 +443,11 @@ const ListView: FC = (props: ListViewProps) => {
{confirmFlow && ( void; + payment: Winning + canSave?: (canSave: boolean) => void + onValueUpdated?: ({ + releaseDate, netAmount, paymentStatus, auditNote, + }: { + releaseDate?: Date + netAmount?: number + paymentStatus?: string + auditNote?: string + }) => void } -const PaymentEditForm: React.FC = (props: PaymentEditFormProps) => { - const [formData, setFormData] = useState({ - description: props.payment.description || '', - }) - const [errors, setErrors] = useState({}) +const PaymentEdit: React.FC = (props: PaymentEditFormProps) => { + const [paymentStatus, setPaymentStatus] = useState('') + const [releaseDate, setReleaseDate] = useState(new Date()) + const [netAmount, setNetAmount] = useState(0) + const [netAmountErrorString, setNetAmountErrorString] = useState('') + const [auditNote, setAuditNote] = useState('') + const [dirty, setDirty] = useState(false) - useEffect(() => { - const validateForm = (): boolean => { - let formIsValid = true - const validationErrors: { [key: string]: string } = {} + const initialValues = useMemo(() => ({ + auditNote: '', + netPayment: props.payment.netPaymentNumber, + paymentStatus: props.payment.status, + releaseDate: props.payment.releaseDateObj, + }), [props.payment]) - if (!formData.description) { - formIsValid = false - validationErrors.description = 'Description is required.' - } + const validateNetAmount = (value: number): boolean => { + if (Number.isNaN(value)) { + setNetAmountErrorString('A valid number is required') + return false + } - setErrors(validationErrors) - props.onErrorStateChanged(!formIsValid) + if (value < 0) { + setNetAmountErrorString('Net Payment must be greater than 0') + return false + } - return formIsValid + if (!/^\d+(\.\d{0,2})?$/.test(value.toString())) { + setNetAmountErrorString('Amount can only have 2 decimal places at most') + return false } - setFormData({ - description: props.payment.description || '', - }) + return true + } + + const handleInputChange = (name: string, value: string | number | Date): void => { + let isValid = true + + switch (name) { + case 'netPayment': + isValid = validateNetAmount(value as number) + if (isValid) { + setNetAmount(value as number) + if (props.onValueUpdated) { + props.onValueUpdated({ + netAmount: value as number, + }) + } + + setNetAmountErrorString('') + } + + break + case 'paymentStatus': + setPaymentStatus(value as string) + if (props.onValueUpdated) { + props.onValueUpdated({ + paymentStatus: value as string, + }) + } - validateForm() - }, [props.payment, formData, props.onErrorStateChanged, props]) + break + case 'releaseDate': + setReleaseDate(value as Date) + if (props.onValueUpdated) { + props.onValueUpdated({ + releaseDate: value as Date, + }) + } - const handleChange = (e: React.ChangeEvent): void => { - const { name, value } : { - name: string; - value: string; - } = e.target + break + case 'auditNote': + setAuditNote(value as string) + if (props.onValueUpdated) { + props.onValueUpdated({ + auditNote: value as string, + }) + } - setFormData(prevState => ({ - ...prevState, - [name]: value, - })) + break + default: + break + } } + useEffect(() => { + setPaymentStatus(props.payment.status) + setReleaseDate(props.payment.releaseDateObj) + setNetAmount(props.payment.netPaymentNumber) + }, [props.payment]) + + useEffect(() => { + const valuesToCheck = [{ + key: 'netPayment', + value: netAmount, + }, { + key: 'paymentStatus', + value: paymentStatus, + }, { + key: 'releaseDate', + value: releaseDate, + }, { + key: 'auditNote', + value: auditNote, + }] + + const isDirty = valuesToCheck.some(x => x.value !== initialValues[x.key as keyof typeof initialValues]) + setDirty(isDirty) + }, [netAmount, paymentStatus, releaseDate, auditNote, initialValues]) + + useEffect(() => { + if (props.canSave) { + if (!dirty) { + props.canSave(false) + } else { + const valuesToCheck = [{ + key: 'netPayment', + value: netAmount, + }, { + key: 'paymentStatus', + value: paymentStatus, + }, { + key: 'releaseDate', + value: releaseDate, + }] + + const haveChange = valuesToCheck.some(x => x.value !== initialValues[x.key as keyof typeof initialValues]) // check if any value has changed that's not the audit note + props.canSave(haveChange && netAmountErrorString.length === 0 && auditNote.length > 0) + } + } + }, [dirty, auditNote, props, netAmountErrorString.length, netAmount, paymentStatus, releaseDate, initialValues]) + return ( -
-
- - - {errors.description &&

{errors.description}

} +
+
+
+ Handle +

{props.payment.handle}

+
+ +
+ Type +

{props.payment.type}

+
+ +
+ Description +

{props.payment.description}

+
+ + handleInputChange('netPayment', parseFloat(e.target.value))} + + /> + handleInputChange('paymentStatus', e.target.value)} + /> + { if (date != null) handleInputChange('releaseDate', date) }} + /> + handleInputChange('auditNote', e.target.value)} + />
- +
) - } -export default PaymentEditForm +export default PaymentEdit diff --git a/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.module.scss b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.module.scss new file mode 100644 index 000000000..651786b69 --- /dev/null +++ b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.module.scss @@ -0,0 +1,42 @@ +.formContainer { + display: flex; + flex-direction: column; +} + +.infoGroup { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 20px; +} + +.infoItem { + display: flex; + flex-direction: column; +} + +.label { + font-weight: bold; + margin-bottom: 5px; +} + +.value { + // Style for your value text +} + +.inputGroup { + display: flex; + flex-direction: column; + gap: 20px; +} + + +.container { + margin: 8px 0; + + .content { + display: flex; + flex-direction: column; + margin-bottom: 0; + } +} diff --git a/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx new file mode 100644 index 000000000..086f3427c --- /dev/null +++ b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx @@ -0,0 +1,124 @@ +/* eslint-disable unicorn/no-null */ +/* eslint-disable max-len */ +/* eslint-disable react/jsx-no-bind */ +import React from 'react' + +import { Button, Collapsible } from '~/libs/ui' + +import { Winning } from '../../models/WinningDetail' + +import styles from './PaymentView.module.scss' + +interface PaymentViewProps { + payment: Winning + auditLines?: { + date: string + userName: string + action: string + }[] +} + +const PaymentView: React.FC = (props: PaymentViewProps) => { + const [view, setView] = React.useState<'details' | 'audit'>('details') + + const handleToggleView = (): void => { + setView(view === 'details' ? 'audit' : 'details') + } + + return ( +
+
+ {view === 'details' && ( + <> +
+ Handle +

{props.payment.handle}

+
+ +
+ Type +

{props.payment.type}

+
+ +
+ Description +

{props.payment.description}

+
+ +
+ Net Payment +

{props.payment.netPaymentNumber.toLocaleString(undefined, { currency: 'USD', style: 'currency' })}

+
+ +
+ Payment Status +

{props.payment.status}

+
+ +
+ Release Date +

{props.payment.releaseDateObj.toLocaleDateString()}

+
+ +
+
+ + )} + + {view === 'audit' && ( + <> +
+ {props.auditLines && props.auditLines.map(line => ( + + { + new Date(line.date) + .toLocaleString() + } + + )} + containerClass={styles.container} + contentClass={styles.content} + > +
+
+

+ User: + {' '} + {line.userName} +

+

+ Action: + {' '} + {line.action} +

+
+
+
+ ))} + {(props.auditLines === undefined || props.auditLines.length === 0) + && ( +
+

No audit data available

+
+ )} +
+
+
+ + )} +
+
+ ) +} + +export default PaymentView diff --git a/src/apps/wallet-admin/src/lib/components/payment-view/index.ts b/src/apps/wallet-admin/src/lib/components/payment-view/index.ts new file mode 100644 index 000000000..aaa9e1f64 --- /dev/null +++ b/src/apps/wallet-admin/src/lib/components/payment-view/index.ts @@ -0,0 +1 @@ +export { default as PaymentView } from './PaymentView' diff --git a/src/apps/wallet-admin/src/lib/components/payments-table/PaymentTable.module.scss b/src/apps/wallet-admin/src/lib/components/payments-table/PaymentTable.module.scss index 0c9f122cb..39ad38e1c 100644 --- a/src/apps/wallet-admin/src/lib/components/payments-table/PaymentTable.module.scss +++ b/src/apps/wallet-admin/src/lib/components/payments-table/PaymentTable.module.scss @@ -94,6 +94,13 @@ table { } } +.actionButtons { + padding-left: 8px; + display: flex; + justify-content: flex-end; + align-items: center; +} + @media (max-width: 768px) { .paymentFooter { flex-direction: column; diff --git a/src/apps/wallet-admin/src/lib/components/payments-table/PaymentTable.tsx b/src/apps/wallet-admin/src/lib/components/payments-table/PaymentTable.tsx index 8bce7a864..5a9c3baef 100644 --- a/src/apps/wallet-admin/src/lib/components/payments-table/PaymentTable.tsx +++ b/src/apps/wallet-admin/src/lib/components/payments-table/PaymentTable.tsx @@ -14,6 +14,7 @@ interface PaymentTableProps { currentPage: number; numPages: number; onPaymentEditClick: (payment: Winning) => void; + onPaymentViewClick: (payment: Winning) => void; onNextPageClick: () => void; onPreviousPageClick: () => void; onPageClick: (pageNumber: number) => void; @@ -56,15 +57,19 @@ const PaymentsTable: React.FC = (props: PaymentTableProps) => {payment.status} {payment.releaseDate} {payment.datePaid} - + {payment.status.toUpperCase() !== 'PAID' && (
- ) - } - - return ( -
-
-

WITHDRAWAL METHODS

- {!isLoading && setupRequired && } -
- -
- PAYMENT PROVIDER}> -

- Topcoder is partnered with several payment providers to send payments to our community members. - Once a provider is set up, payments will be routed to your selected payment provider at the - completion of work. Currently, members can be paid through one of the following providers: - Payoneer® or PayPal®. -

- - {isLoading && } - - {!isLoading && selectedPaymentProvider === undefined && renderProviders()} - {!isLoading && selectedPaymentProvider !== undefined && renderConnectedProvider()} - -

- Provider details are based on the latest information from their official sites; we advise - confirming the current terms directly before finalizing your payment option. -

-
-
- - {paymentInfoModalFlow && ( - { - setOtpFlow({ - ...response, - type: 'SETUP_PAYMENT_PROVIDER', - }) - fetchPaymentProviders(false) - }) - .catch((err: Error) => { - toast.error( - err.message ?? 'Something went wrong. Please try again.', - { position: toast.POSITION.BOTTOM_RIGHT }, - ) - }) - }} - /> - )} - {otpFlow && ( - - )} -
- ) -} - -export default PaymentsTab diff --git a/src/apps/wallet-admin/src/home/tabs/payments-methods/index.ts b/src/apps/wallet-admin/src/home/tabs/payments-methods/index.ts deleted file mode 100644 index 8dc41bc69..000000000 --- a/src/apps/wallet-admin/src/home/tabs/payments-methods/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as PaymentsTab } from './PaymentsTab' diff --git a/src/apps/wallet-admin/src/home/tabs/payments-methods/payment-info-modal/PaymentInfoModal.module.scss b/src/apps/wallet-admin/src/home/tabs/payments-methods/payment-info-modal/PaymentInfoModal.module.scss deleted file mode 100644 index 3917b7e01..000000000 --- a/src/apps/wallet-admin/src/home/tabs/payments-methods/payment-info-modal/PaymentInfoModal.module.scss +++ /dev/null @@ -1,13 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.infoModal { - :global(.react-responsive-modal-closeButton) { - display: flex; - } - - .modalContent { - display: flex; - flex-direction: column; - gap: 25px; - } -} diff --git a/src/apps/wallet-admin/src/home/tabs/payments-methods/payment-info-modal/PaymentInfoModal.tsx b/src/apps/wallet-admin/src/home/tabs/payments-methods/payment-info-modal/PaymentInfoModal.tsx deleted file mode 100644 index cdb6df8be..000000000 --- a/src/apps/wallet-admin/src/home/tabs/payments-methods/payment-info-modal/PaymentInfoModal.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* eslint-disable react/jsx-wrap-multilines */ -/* eslint-disable react/jsx-no-bind */ -import { FC } from 'react' - -import { BaseModal, Button, IconOutline, LinkButton } from '~/libs/ui' - -import { PayoneerLogo, PayPalLogo } from '../../../../lib' - -import styles from './PaymentInfoModal.module.scss' - -interface PaymentInfoModalProps { - selectedPaymentProvider: string - handlePaymentSelection: (provider: string) => void - handleModalClose: () => void -} - -function renderPayoneer(): JSX.Element { - return ( - <> - -

- You can elect to receive payments through Payoneer either to your Payoneer prepaid MasterCard or by - using their Global Bank Transfer service. The Payoneer Bank Transfer Service offers a local bank - transfer option (where available) and a wire transfer option. Certain fees may apply. -

-

- You will be directed to Payoneer's website in a new tab to complete your connection. Please make - sure your account is fully verified to ensure withdrawal success. - - You can return here after finishing up on Payoneer's site. - -

- - ) -} - -function renderPaypal(): JSX.Element { - return ( - <> - -

You can elect to receive payments deposited directly to your PayPal account. Certain fees may apply.

-

- You will be directed to PayPal's website in a new tab to complete your connection. Please make - sure your account is fully verified to ensure withdrawal success. - {' '} - - You can return here after finishing up - on PayPal's site. - -

- - ) -} - -const PaymentInfoModal: FC = (props: PaymentInfoModalProps) => ( - - -