diff --git a/src/CONST.ts b/src/CONST.ts index ae78c2f02a05..19720c05a93c 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1149,6 +1149,11 @@ const CONST = { URL: 'url', }, + INPUT_AUTOGROW_DIRECTION: { + LEFT: 'left', + RIGHT: 'right', + }, + YOUR_LOCATION_TEXT: 'Your Location', ATTACHMENT_MESSAGE_TEXT: '[Attachment]', diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index e0a494ec6fb1..e5980a397d37 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -28,6 +28,9 @@ type AmountTextInputProps = { /** Style for the container */ touchableInputWrapperStyle?: StyleProp; + /** Whether to disable keyboard */ + disableKeyboard?: boolean; + /** Function to call to handle key presses in the text input */ onKeyPress?: (event: NativeSyntheticEvent) => void; @@ -36,22 +39,34 @@ type AmountTextInputProps = { } & Pick; function AmountTextInput( - {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress, containerStyle, ...rest}: AmountTextInputProps, + { + formattedAmount, + onChangeAmount, + placeholder, + selection, + onSelectionChange, + style, + touchableInputWrapperStyle, + onKeyPress, + containerStyle, + disableKeyboard = true, + ...rest + }: AmountTextInputProps, ref: ForwardedRef, ) { return ( ; @@ -62,6 +65,11 @@ type MoneyRequestAmountInputProps = { /** Style for the touchable input wrapper */ touchableInputWrapperStyle?: StyleProp; + + /** Whether we want to format the display amount on blur */ + formatAmountOnBlur?: boolean; + + maxLength?: number; }; type Selection = { @@ -88,6 +96,9 @@ function MoneyRequestAmountInput( hideCurrencySymbol = false, shouldUpdateSelection = true, moneyRequestAmountInputRef, + disableKeyboard = true, + formatAmountOnBlur, + maxLength, ...props }: MoneyRequestAmountInputProps, forwardedRef: ForwardedRef, @@ -117,9 +128,12 @@ function MoneyRequestAmountInput( // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value // More info: https://github.com/Expensify/App/issues/16974 const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + const finalAmount = newAmountWithoutSpaces.includes('.') + ? MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces) + : MoneyRequestUtils.replaceCommasWithPeriod(newAmountWithoutSpaces); // Use a shallow copy of selection to trigger setSelection // More info: https://github.com/Expensify/App/issues/16385 - if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) { + if (!MoneyRequestUtils.validateAmount(finalAmount, decimals)) { setSelection((prevSelection) => ({...prevSelection})); return; } @@ -128,7 +142,7 @@ function MoneyRequestAmountInput( let hasSelectionBeenSet = false; setCurrentAmount((prevAmount) => { - const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(finalAmount); const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current; if (!hasSelectionBeenSet) { hasSelectionBeenSet = true; @@ -160,15 +174,21 @@ function MoneyRequestAmountInput( })); useEffect(() => { - if (!currency || typeof amount !== 'number') { + if (!currency || typeof amount !== 'number' || (formatAmountOnBlur && textInput.current?.isFocused())) { return; } - const frontendAmount = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; + const frontendAmount = formatAmountOnBlur ? CurrencyUtils.convertToDisplayStringWithoutCurrency(amount, currency) : CurrencyUtils.convertToFrontendAmount(amount).toString(); setCurrentAmount(frontendAmount); - setSelection({ - start: frontendAmount.length, - end: frontendAmount.length, - }); + + // Only update selection if the amount prop was changed from the outside and is not the same as the current amount we just computed + // In the line below the currentAmount is not immediately updated, it should still hold the previous value. + if (frontendAmount !== currentAmount) { + setSelection({ + start: frontendAmount.length, + end: frontendAmount.length, + }); + } + // we want to re-initialize the state only when the amount changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [amount]); @@ -204,13 +224,30 @@ function MoneyRequestAmountInput( forwardDeletePressedRef.current = key === 'delete' || ((operatingSystem === CONST.OS.MAC_OS || operatingSystem === CONST.OS.IOS) && nativeEvent?.ctrlKey && key === 'd'); }; + const formatAmount = useCallback(() => { + if (!formatAmountOnBlur) { + return; + } + const formattedAmount = CurrencyUtils.convertToDisplayStringWithoutCurrency(amount, currency); + if (maxLength && formattedAmount.length > maxLength) { + return; + } + setCurrentAmount(formattedAmount); + setSelection({ + start: formattedAmount.length, + end: formattedAmount.length, + }); + }, [amount, currency, formatAmountOnBlur, maxLength]); + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); return ( { if (typeof forwardedRef === 'function') { @@ -241,6 +278,7 @@ function MoneyRequestAmountInput( prefixStyle={props.prefixStyle} prefixContainerStyle={props.prefixContainerStyle} touchableInputWrapperStyle={props.touchableInputWrapperStyle} + maxLength={maxLength} /> ); } @@ -248,4 +286,4 @@ function MoneyRequestAmountInput( MoneyRequestAmountInput.displayName = 'MoneyRequestAmountInput'; export default React.forwardRef(MoneyRequestAmountInput); -export type {CurrentMoney, MoneyRequestAmountInputRef}; +export type {CurrentMoney, MoneyRequestAmountInputProps, MoneyRequestAmountInputRef}; diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 567398663fc7..b97578210ad9 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -7,10 +7,12 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; @@ -29,7 +31,6 @@ import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; -import * as UserUtils from '@libs/UserUtils'; import * as IOU from '@userActions/IOU'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; @@ -39,6 +40,7 @@ import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type {SplitShares} from '@src/types/onyx/Transaction'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import type {DropdownOption} from './ButtonWithDropdownMenu/types'; import ConfirmedRoute from './ConfirmedRoute'; @@ -46,7 +48,6 @@ import ConfirmModal from './ConfirmModal'; import FormHelpMessage from './FormHelpMessage'; import MenuItem from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import {usePersonalDetails} from './OnyxProvider'; import OptionsSelector from './OptionsSelector'; import PDFThumbnail from './PDFThumbnail'; import ReceiptEmptyState from './ReceiptEmptyState'; @@ -86,6 +87,9 @@ type MoneyRequestConfirmationListOnyxProps = { /** The list of all policies */ allPolicies: OnyxCollection; + + /** List of currencies */ + currencyList: OnyxEntry; }; type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { @@ -134,9 +138,6 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** Payee of the expense with login */ payeePersonalDetails?: OnyxEntry; - /** Can the participants be modified or not */ - canModifyParticipants?: boolean; - /** Should the list be read only, and not editable? */ isReadOnly?: boolean; @@ -164,9 +165,6 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** Whether the expense is a distance expense */ isDistanceRequest?: boolean; - /** Whether the expense is a scan expense */ - isScanRequest?: boolean; - /** Whether we're editing a split expense */ isEditingSplitBill?: boolean; @@ -197,7 +195,6 @@ function MoneyRequestConfirmationList({ onConfirm, onSelectParticipant, iouType = CONST.IOU.TYPE.SUBMIT, - isScanRequest = false, iouAmount, policyCategories: policyCategoriesReal, policyCategoriesDraft, @@ -215,7 +212,6 @@ function MoneyRequestConfirmationList({ hasMultipleParticipants, selectedParticipants: selectedParticipantsProp, payeePersonalDetails: payeePersonalDetailsProp, - canModifyParticipants: canModifyParticipantsProp = false, session, isReadOnly = false, bankAccountRoute = '', @@ -234,14 +230,15 @@ function MoneyRequestConfirmationList({ lastSelectedDistanceRates, allPolicies, action = CONST.IOU.ACTION.CREATE, + currencyList, }: MoneyRequestConfirmationListProps) { const policy = policyReal ?? policyDraft; const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; const theme = useTheme(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {translate, toLocaleDigit} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const personalDetails = usePersonalDetails(); const {canUseP2PDistanceRequests, canUseViolations} = usePermissions(iouType); const {isOffline} = useNetwork(); @@ -250,6 +247,7 @@ function MoneyRequestConfirmationList({ const isTypeSend = iouType === CONST.IOU.TYPE.PAY; const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const isTypeInvoice = iouType === CONST.IOU.TYPE.INVOICE; + const isScanRequest = useMemo(() => TransactionUtils.isScanRequest(transaction), [transaction]); const transactionID = transaction?.transactionID ?? ''; const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; @@ -326,7 +324,7 @@ function MoneyRequestConfirmationList({ const previousTransactionAmount = usePrevious(transaction?.amount); const isFocused = useIsFocused(); - const [formError, setFormError] = useState(''); + const [formError, debouncedFormError, setFormError] = useDebouncedState(''); const [didConfirm, setDidConfirm] = useState(false); const [didConfirmSplit, setDidConfirmSplit] = useState(false); @@ -345,9 +343,8 @@ function MoneyRequestConfirmationList({ return (!!hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]); - const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; - const isMerchantRequired = isPolicyExpenseChat && !isScanRequest && shouldShowMerchant; - + const isMerchantEmpty = useMemo(() => !iouMerchant || TransactionUtils.isMerchantMissing(transaction), [transaction, iouMerchant]); + const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || isEditingSplitBill) && shouldShowMerchant; const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; const isCategoryRequired = canUseViolations && !!policy?.requiresCategory; @@ -363,6 +360,8 @@ function MoneyRequestConfirmationList({ } // reset the form error whenever the screen gains or loses focus setFormError(''); + + // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); useEffect(() => { @@ -386,17 +385,6 @@ function MoneyRequestConfirmationList({ IOU.setMoneyRequestTaxAmount(transactionID, amountInSmallestCurrencyUnits, true); }, [taxRates?.defaultValue, transaction, transactionID, previousTransactionAmount]); - /** - * Returns the participants with amount - */ - const getParticipantsWithAmount = useCallback( - (participantsList: Participant[]) => { - const amount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? ''); - return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(participantsList, amount > 0 ? CurrencyUtils.convertToDisplayString(amount, iouCurrencyCode) : ''); - }, - [iouAmount, iouCurrencyCode], - ); - // If completing a split expense fails, set didConfirm to false to allow the user to edit the fields again if (isEditingSplitBill && didConfirm) { setDidConfirm(false); @@ -427,56 +415,121 @@ function MoneyRequestConfirmationList({ ]; }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount, isTypeInvoice]); - const selectedParticipants = useMemo(() => selectedParticipantsProp.filter((participant) => participant.selected), [selectedParticipantsProp]); - const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); - const payeeTooltipDetails = useMemo( - () => ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([payeePersonalDetails.accountID], personalDetails), false), - [payeePersonalDetails.accountID, personalDetails], - ); - const payeeIcons = [ - { - source: UserUtils.getAvatar(payeePersonalDetails.avatar, payeePersonalDetails.accountID) ?? '', - name: payeePersonalDetails.login ?? '', - type: CONST.ICON_TYPE_AVATAR, - id: payeePersonalDetails.accountID, - }, - ]; - const canModifyParticipants = !isReadOnly && canModifyParticipantsProp && hasMultipleParticipants; - const shouldDisablePaidBySection = canModifyParticipants; - const optionSelectorSections = useMemo(() => { - const sections = []; - const unselectedParticipants = selectedParticipantsProp.filter((participant) => !participant.selected); - if (hasMultipleParticipants) { - const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipants); - let formattedParticipantsList = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])]; - - if (!canModifyParticipants) { - formattedParticipantsList = formattedParticipantsList.map((participant) => ({ - ...participant, - isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), - })); + const onSplitShareChange = useCallback( + (accountID: number, value: number) => { + if (!transaction?.transactionID) { + return; } + const amountInCents = CurrencyUtils.convertToBackendAmount(value); + IOU.setIndividualShare(transaction?.transactionID, accountID, amountInCents); + }, + [transaction], + ); + + useEffect(() => { + if (!isTypeSplit || !transaction?.splitShares) { + return; + } - const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, iouAmount, iouCurrencyCode ?? '', true); + const splitSharesMap: SplitShares = transaction.splitShares; + const shares: number[] = Object.values(splitSharesMap).map((splitShare) => splitShare?.amount ?? 0); + const sumOfShares = shares?.reduce((prev, current): number => prev + current, 0); + if (sumOfShares !== iouAmount) { + setFormError(translate('iou.error.invalidSplit')); + return; + } - const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( - payeePersonalDetails, - iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode) : '', - ); + const participantsWithAmount = Object.keys(transaction?.splitShares ?? {}) + .filter((accountID: string): boolean => (transaction?.splitShares?.[Number(accountID)]?.amount ?? 0) > 0) + .map((accountID) => Number(accountID)); + + // A split must have at least two participants with amounts bigger than 0 + if (participantsWithAmount.length === 1) { + setFormError(translate('iou.error.invalidSplitParticipants')); + return; + } + + setFormError(''); + }, [isTypeSplit, transaction?.splitShares, iouAmount, iouCurrencyCode, setFormError, translate]); + + useEffect(() => { + if (!isTypeSplit || !transaction?.splitShares) { + return; + } + IOU.adjustRemainingSplitShares(transaction); + }, [isTypeSplit, transaction]); + const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); + const selectedParticipants = useMemo(() => selectedParticipantsProp.filter((participant) => participant.selected), [selectedParticipantsProp]); + const shouldShowReadOnlySplits = useMemo(() => isPolicyExpenseChat || isReadOnly || isScanRequest, [isPolicyExpenseChat, isReadOnly, isScanRequest]); + + const splitParticipants = useMemo(() => { + const payeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails); + if (shouldShowReadOnlySplits) { + return [payeeOption, ...selectedParticipants].map((participantOption: Participant) => { + const isPayer = participantOption.accountID === payeeOption.accountID; + let amount: number | undefined = 0; + if (iouAmount > 0) { + amount = + isPolicyExpenseChat || !transaction?.comment?.splits + ? IOUUtils.calculateAmount(selectedParticipants.length, iouAmount, iouCurrencyCode ?? '', isPayer) + : transaction.comment.splits.find((split) => split.accountID === participantOption.accountID)?.amount; + } + return { + ...participantOption, + descriptiveText: amount ? CurrencyUtils.convertToDisplayString(amount, iouCurrencyCode) : '', + }; + }); + } + + const currencySymbol = currencyList?.[iouCurrencyCode ?? '']?.symbol ?? iouCurrencyCode; + const prefixPadding = StyleUtils.getCharacterPadding(currencySymbol ?? ''); + const formattedTotalAmount = CurrencyUtils.convertToDisplayStringWithoutCurrency(iouAmount, iouCurrencyCode); + const amountWidth = StyleUtils.getWidthStyle(formattedTotalAmount.length * 9 + prefixPadding); + return [payeeOption, ...selectedParticipants].map((participantOption: Participant) => ({ + ...participantOption, + tabIndex: -1, + shouldShowAmountInput: true, + amountInputProps: { + amount: transaction?.splitShares?.[participantOption.accountID ?? 0]?.amount, + currency: iouCurrencyCode, + prefixCharacter: currencySymbol, + containerStyle: [amountWidth], + inputStyle: [amountWidth], + maxLength: formattedTotalAmount.length, + onAmountChange: (value: string) => onSplitShareChange(participantOption.accountID ?? 0, Number(value)), + }, + })); + }, [transaction, iouCurrencyCode, isPolicyExpenseChat, onSplitShareChange, payeePersonalDetails, selectedParticipants, currencyList, iouAmount, shouldShowReadOnlySplits, StyleUtils]); + + const isSplitModified = useMemo(() => { + if (!transaction?.splitShares) { + return; + } + return Object.keys(transaction.splitShares).some((key) => transaction.splitShares?.[Number(key) ?? -1]?.isModified); + }, [transaction?.splitShares]); + + const optionSelectorSections = useMemo(() => { + const sections = []; + if (hasMultipleParticipants) { sections.push( - { - title: translate('moneyRequestConfirmationList.paidBy'), - data: [formattedPayeeOption], - shouldShow: true, - isDisabled: shouldDisablePaidBySection, - }, - { - title: translate('moneyRequestConfirmationList.splitWith'), - data: formattedParticipantsList, - shouldShow: true, - }, + ...[ + { + title: translate('moneyRequestConfirmationList.paidBy'), + data: [OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)], + shouldShow: true, + }, + { + title: translate('moneyRequestConfirmationList.splitAmounts'), + data: splitParticipants, + shouldShow: true, + shouldShowActionButton: !shouldShowReadOnlySplits && isSplitModified, + onActionButtonPress: () => IOU.resetSplitShares(transaction), + actionButtonTitle: translate('common.reset'), + }, + ], ); + sections.push(); } else { const formattedSelectedParticipants = selectedParticipants.map((participant) => ({ isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1), @@ -489,18 +542,7 @@ function MoneyRequestConfirmationList({ }); } return sections; - }, [ - selectedParticipants, - selectedParticipantsProp, - hasMultipleParticipants, - iouAmount, - iouCurrencyCode, - getParticipantsWithAmount, - payeePersonalDetails, - translate, - shouldDisablePaidBySection, - canModifyParticipants, - ]); + }, [selectedParticipants, hasMultipleParticipants, translate, splitParticipants, transaction, shouldShowReadOnlySplits, isSplitModified, payeePersonalDetails]); const selectedOptions = useMemo(() => { if (!hasMultipleParticipants) { @@ -616,6 +658,10 @@ function MoneyRequestConfirmationList({ return; } + if (formError) { + return; + } + if (iouType === CONST.IOU.TYPE.PAY) { if (!paymentMethod) { return; @@ -658,6 +704,8 @@ function MoneyRequestConfirmationList({ isDistanceRequestWithPendingRoute, iouAmount, isEditingSplitBill, + formError, + setFormError, onConfirm, ], ); @@ -709,14 +757,28 @@ function MoneyRequestConfirmationList({ )} {button} ); - }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2]); + }, [ + isReadOnly, + iouType, + selectedParticipants.length, + confirm, + bankAccountRoute, + iouCurrencyCode, + policyID, + splitOrRequestOptions, + formError, + debouncedFormError, + shouldShowReadOnlySplits, + styles.ph1, + styles.mb2, + ]); // An intermediate structure that helps us classify the fields as "primary" and "supplementary". // The primary fields are always shown to the user, while an extra action is needed to reveal the supplementary ones. @@ -1020,104 +1082,82 @@ function MoneyRequestConfirmationList({ ); return ( - <> - {/** Hide it temporarily, it will back when https://github.com/Expensify/App/pull/40386 is merged */} - {isTypeSplit && action === CONST.IOU.ACTION.CREATE && false && ( + /** @ts-expect-error This component is deprecated and will not be migrated to TypeScript (context: https://expensify.slack.com/archives/C01GTK53T8Q/p1709232289899589?thread_ts=1709156803.359359&cid=C01GTK53T8Q) */ + + {isDistanceRequest && ( + + + + )} + {isTypeInvoice && ( { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SPLIT_PAYER.getRoute(action, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), - ); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams())); }} - shouldShowRightIcon={!isPolicyExpenseChat && !isReadOnly} - titleWithTooltips={payeePersonalDetails?.isOptimisticPersonalDetail ? undefined : payeeTooltipDetails} + style={styles.moneyRequestMenuItem} + labelStyle={styles.mt2} + titleStyle={styles.flex1} + disabled={didConfirm || !canUpdateSenderWorkspace} /> )} - {/** @ts-expect-error This component is deprecated and will not be migrated to TypeScript (context: https://expensify.slack.com/archives/C01GTK53T8Q/p1709232289899589?thread_ts=1709156803.359359&cid=C01GTK53T8Q) */} - - {isDistanceRequest && ( - - - - )} - {isTypeInvoice && ( - { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams())); - }} - style={styles.moneyRequestMenuItem} - labelStyle={styles.mt2} - titleStyle={styles.flex1} - disabled={didConfirm || !canUpdateSenderWorkspace} - /> - )} - {!isDistanceRequest && - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (receiptImage || receiptThumbnail - ? receiptThumbnailContent - : // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") - PolicyUtils.isPaidGroupPolicy(policy) && - !isDistanceRequest && - iouType === CONST.IOU.TYPE.SUBMIT && ( - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ) - } - /> - ))} - {primaryFields} - {!shouldShowAllFields && ( - - )} - {shouldShowAllFields && supplementaryFields} - + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + /> + ))} + {primaryFields} + {!shouldShowAllFields && ( + - - + )} + {shouldShowAllFields && supplementaryFields} + + ); } @@ -1156,4 +1196,7 @@ export default withOnyx= 2} onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (event) => event.preventDefault() : undefined} + tabIndex={option.tabIndex ?? 0} > @@ -251,6 +253,27 @@ function OptionRow({ {option.descriptiveText} ) : null} + {option.shouldShowAmountInput && option.amountInputProps ? ( + + ) : null} {!isSelected && option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( { + const renderSectionHeader = ({section: {title, shouldShow, shouldShowActionButton, onActionButtonPress, actionButtonTitle}}: {section: OptionsListDataWithIndexOffset}) => { if (!title && shouldShow && !hideSectionHeaders && sectionHeaderStyle) { return ; } @@ -216,8 +217,18 @@ function BaseOptionsList( // We do this so that we can reference the height in `getItemLayout` – // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. - + {title} + {shouldShowActionButton && ( + + {actionButtonTitle} + + )} ); } diff --git a/src/components/OptionsList/types.ts b/src/components/OptionsList/types.ts index 7f23da965f39..c575a82332b6 100644 --- a/src/components/OptionsList/types.ts +++ b/src/components/OptionsList/types.ts @@ -18,6 +18,15 @@ type Section = { /** Whether this section is disabled or not */ isDisabled?: boolean; + + /** Whether to show an action button in the section header */ + shouldShowActionButton?: boolean; + + /** Title of the action button */ + actionButtonTitle?: string; + + /** Callback of the action button */ + onActionButtonPress?: () => void; }; type SectionWithIndexOffset = Section & { diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 7d71560d464c..33bb67605ecf 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -1,6 +1,6 @@ import {truncate} from 'lodash'; import lodashSortBy from 'lodash/sortBy'; -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; import ConfirmedRoute from '@components/ConfirmedRoute'; @@ -212,6 +212,17 @@ function MoneyRequestPreviewContent({ const displayAmount = isDeleted ? getDisplayDeleteAmountText() : getDisplayAmountText(); + const shouldShowSplitShare = isBillSplit && !!requestAmount && requestAmount > 0; + + // If available, retrieve the split share from the splits object of the transaction, if not, display an even share. + const splitShare = useMemo( + () => + shouldShowSplitShare && + (transaction?.comment?.splits?.find((split) => split.accountID === sessionAccountID)?.amount ?? + IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency ?? '', action.actorAccountID === sessionAccountID)), + [shouldShowSplitShare, isPolicyExpenseChat, action.actorAccountID, participantAccountIDs.length, transaction?.comment?.splits, requestAmount, requestCurrency, sessionAccountID], + ); + const childContainer = ( {merchantOrDescription} )} - {isBillSplit && participantAccountIDs.length > 0 && !!requestAmount && requestAmount > 0 && ( + {splitShare && ( - {translate('iou.amountEach', { - amount: CurrencyUtils.convertToDisplayString( - IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency ?? ''), - requestCurrency, - ), - })} + {translate('iou.yourSplit', {amount: CurrencyUtils.convertToDisplayString(splitShare ?? 0, requestCurrency ?? '')})} )} diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index bc016617b9e3..c73509aa7b8f 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -255,13 +255,14 @@ function BaseTextInput( ]); const isMultiline = multiline || autoGrowHeight; - /* To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome, - make sure to include the `lineHeight`. - Reference: https://github.com/Expensify/App/issues/26735 - For other platforms, explicitly remove `lineHeight` from single-line inputs - to prevent long text from disappearing once it exceeds the input space. - See https://github.com/Expensify/App/issues/13802 */ - + /** + * To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome, + * make sure to include the `lineHeight`. + * Reference: https://github.com/Expensify/App/issues/26735 + * For other platforms, explicitly remove `lineHeight` from single-line inputs + * to prevent long text from disappearing once it exceeds the input space. + * See https://github.com/Expensify/App/issues/13802 + */ const lineHeight = useMemo(() => { if (Browser.isSafari() || Browser.isMobileChrome()) { const lineHeightValue = StyleSheet.flatten(inputStyle).lineHeight; diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts index 78d4158cf77f..a55436225bbf 100644 --- a/src/components/TextInputWithCurrencySymbol/types.ts +++ b/src/components/TextInputWithCurrencySymbol/types.ts @@ -1,4 +1,4 @@ -import type {NativeSyntheticEvent, StyleProp, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputSelectionChangeEventData, TextStyle, ViewStyle} from 'react-native'; import type {TextSelection} from '@components/Composer/types'; import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types'; @@ -27,12 +27,20 @@ type TextInputWithCurrencySymbolProps = { /** Function to call to handle key presses in the text input */ onKeyPress?: (event: NativeSyntheticEvent) => void; + /** + * Callback that is called when the text input is blurred + */ + onBlur?: ((e: NativeSyntheticEvent) => void) | undefined; + /** Whether the currency symbol is pressable */ isCurrencyPressable: boolean; /** Whether to hide the currency symbol */ hideCurrencySymbol?: boolean; + /** Whether to disable native keyboard on mobile */ + disableKeyboard?: boolean; + /** Extra symbol to display */ extraSymbol?: React.ReactNode; @@ -53,6 +61,8 @@ type TextInputWithCurrencySymbolProps = { /** Customizes the touchable wrapper of the TextInput component */ touchableInputWrapperStyle?: StyleProp; + + maxLength?: number; } & Pick; export default TextInputWithCurrencySymbolProps; diff --git a/src/languages/en.ts b/src/languages/en.ts index b80428166fff..4f76a2fa3a5f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7,7 +7,6 @@ import type { AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, - AmountEachParams, ApprovedAmountParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, @@ -82,6 +81,7 @@ import type { UpdatedTheRequestParams, UsePlusButtonParams, UserIsAlreadyMemberParams, + UserSplitParams, ViolationsAutoReportedRejectedExpenseParams, ViolationsCashExpenseWithNoReceiptParams, ViolationsConversionSurchargeParams, @@ -406,7 +406,6 @@ export default { }, moneyRequestConfirmationList: { paidBy: 'Paid by', - splitWith: 'Split with', splitAmounts: 'Split amounts', whatsItFor: "What's it for?", }, @@ -676,7 +675,7 @@ export default { trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`, - amountEach: ({amount}: AmountEachParams) => `${amount} each`, + yourSplit: ({amount}: UserSplitParams) => `Your split ${amount}`, payerOwesAmount: ({payer, amount, comment}: PayerOwesAmountParams) => `${payer} owes ${amount}${comment ? ` for ${comment}` : ''}`, payerOwes: ({payer}: PayerOwesParams) => `${payer} owes: `, payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer ? `${payer} ` : ''}paid ${amount}`, @@ -713,7 +712,8 @@ export default { invalidCategoryLength: 'The length of the category chosen exceeds the maximum allowed (255). Please choose a different or shorten the category name first.', invalidAmount: 'Please enter a valid amount before continuing.', invalidTaxAmount: ({amount}: RequestAmountParams) => `Maximum tax amount is ${amount}`, - invalidSplit: 'Split amounts do not equal total amount.', + invalidSplit: 'The sum of splits must equal the total amount.', + invalidSplitParticipants: 'Enter an amount greater than zero for at least two participants.', other: 'Unexpected error, please try again later.', genericCreateFailureMessage: 'Unexpected error submitting this expense. Please try again later.', genericCreateInvoiceFailureMessage: 'Unexpected error sending invoice, please try again later.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 8ddba987d6f4..33e37a819a87 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5,7 +5,6 @@ import type { AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, - AmountEachParams, ApprovedAmountParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, @@ -80,6 +79,7 @@ import type { UpdatedTheRequestParams, UsePlusButtonParams, UserIsAlreadyMemberParams, + UserSplitParams, ViolationsAutoReportedRejectedExpenseParams, ViolationsCashExpenseWithNoReceiptParams, ViolationsConversionSurchargeParams, @@ -397,7 +397,6 @@ export default { }, moneyRequestConfirmationList: { paidBy: 'Pagado por', - splitWith: 'Dividir con', splitAmounts: 'Importes a dividir', whatsItFor: '¿Para qué es?', }, @@ -669,7 +668,7 @@ export default { trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `realizó un seguimiento de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`, - amountEach: ({amount}: AmountEachParams) => `${amount} cada uno`, + yourSplit: ({amount}: UserSplitParams) => `Tu parte ${amount}`, payerOwesAmount: ({payer, amount, comment}: PayerOwesAmountParams) => `${payer} debe ${amount}${comment ? ` para ${comment}` : ''}`, payerOwes: ({payer}: PayerOwesParams) => `${payer} debe: `, payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount}`, @@ -708,7 +707,8 @@ export default { invalidCategoryLength: 'El largo de la categoría escogida excede el máximo permitido (255). Por favor, escoge otra categoría o acorta la categoría primero.', invalidAmount: 'Por favor, ingresa un importe válido antes de continuar.', invalidTaxAmount: ({amount}: RequestAmountParams) => `El importe máximo del impuesto es ${amount}`, - invalidSplit: 'La suma de las partes no equivale al importe total.', + invalidSplit: 'La suma de las partes debe ser igual al importe total.', + invalidSplitParticipants: 'Introduce un importe superior a cero para al menos dos participantes.', other: 'Error inesperado, por favor inténtalo más tarde.', genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, inténtalo más tarde.', genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura, inténtalo de nuevo más tarde.', diff --git a/src/languages/types.ts b/src/languages/types.ts index 23c892bc73e1..e2e7e26e696b 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -112,7 +112,7 @@ type SplitAmountParams = {amount: string}; type DidSplitAmountMessageParams = {formattedAmount: string; comment: string}; -type AmountEachParams = {amount: string}; +type UserSplitParams = {amount: string}; type PayerOwesAmountParams = {payer: string; amount: number | string; comment?: string}; @@ -303,7 +303,7 @@ export type { ApprovedAmountParams, AddressLineParams, AlreadySignedInParams, - AmountEachParams, + UserSplitParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartTwo, diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 95970d2a9582..ec2423833087 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -124,6 +124,25 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE }); } +/** + * Acts the same as `convertAmountToDisplayString` but the result string does not contain currency + */ +function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: string = CONST.CURRENCY.USD) { + const convertedAmount = convertToFrontendAmount(amountInCents); + return NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + style: 'currency', + currency, + + // We are forcing the number of decimals because we override the default number of decimals in the backend for RSD + // See: https://github.com/Expensify/PHP-Libs/pull/834 + minimumFractionDigits: currency === 'RSD' ? getCurrencyDecimals(currency) : undefined, + }) + .filter((x) => x.type !== 'currency') + .filter((x) => x.type !== 'literal' || x.value.trim().length !== 0) + .map((x) => x.value) + .join(''); +} + /** * Checks if passed currency code is a valid currency based on currency list */ @@ -142,5 +161,6 @@ export { convertToFrontendAmount, convertToDisplayString, convertAmountToDisplayString, + convertToDisplayStringWithoutCurrency, isValidCurrencyCode, }; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 2da048ffab4f..1d55c0f49356 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -17,6 +17,10 @@ function stripSpacesFromAmount(amount: string): string { return amount.replace(/\s+/g, ''); } +function replaceCommasWithPeriod(amount: string): string { + return amount.replace(/,+/g, '.'); +} + /** * Strip decimals from the amount */ @@ -91,4 +95,4 @@ function isScanRequest(selectedTab: SelectedTabRequest): boolean { return selectedTab === CONST.TAB_REQUEST.SCAN; } -export {addLeadingZero, isDistanceRequest, isScanRequest, replaceAllDigits, stripCommaFromAmount, stripDecimalsFromAmount, stripSpacesFromAmount, validateAmount}; +export {addLeadingZero, isDistanceRequest, isScanRequest, replaceAllDigits, stripCommaFromAmount, stripDecimalsFromAmount, stripSpacesFromAmount, replaceCommasWithPeriod, validateAmount}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b6aa53614dbe..641d3ddaa268 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13,6 +13,7 @@ import type {FileObject} from '@components/AttachmentModal'; import * as Expensicons from '@components/Icon/Expensicons'; import * as defaultGroupAvatars from '@components/Icon/GroupDefaultAvatars'; import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars'; +import type {MoneyRequestAmountInputProps} from '@components/MoneyRequestAmountInput'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types'; @@ -448,6 +449,10 @@ type OptionData = { reportID?: string; enabled?: boolean; data?: Partial; + transactionThreadReportID?: string | null; + shouldShowAmountInput?: boolean; + amountInputProps?: MoneyRequestAmountInputProps; + tabIndex?: 0 | -1; } & Report; type OnyxDataTaskAssigneeChat = { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 4c582b8b1e7d..79ee20971e5d 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -56,7 +56,7 @@ import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import type {IOUMessage, PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; -import type {Comment, Receipt, ReceiptSource, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; +import type {Comment, Receipt, ReceiptSource, SplitShares, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as CachedPDFPaths from './CachedPDFPaths'; @@ -3655,10 +3655,10 @@ function createSplitsAndOnyxData( created: string, category: string, tag: string, + splitShares: SplitShares = {}, existingSplitChatReportID = '', billable = false, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL, - splitPayerAccountIDs: number[] = [], ): SplitsAndOnyxData { const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin); const participantAccountIDs = participants.map((participant) => Number(participant.accountID)); @@ -3833,19 +3833,21 @@ function createSplitsAndOnyxData( } // Loop through participants creating individual chats, iouReports and reportActionIDs as needed - const splits: Split[] = [ - { - email: currentUserEmailForIOUSplit, - accountID: currentUserAccountID, - amount: IOUUtils.calculateAmount(participants.length, amount, currency, splitPayerAccountIDs.includes(currentUserAccountID)), - }, - ]; + const currentUserAmount = splitShares?.[currentUserAccountID]?.amount ?? IOUUtils.calculateAmount(participants.length, amount, currency, true); + + const splits: Split[] = [{email: currentUserEmailForIOUSplit, accountID: currentUserAccountID, amount: currentUserAmount}]; const hasMultipleParticipants = participants.length > 1; participants.forEach((participant) => { - const splitAmount = IOUUtils.calculateAmount(participants.length, amount, currency, splitPayerAccountIDs.includes(participant.accountID ?? 0)); // In a case when a participant is a workspace, even when a current user is not an owner of the workspace const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(participant); + const splitAmount = splitShares?.[participant.accountID ?? -1]?.amount ?? IOUUtils.calculateAmount(participants.length, amount, currency, false); + + // To exclude someone from a split, the amount can be 0. The scenario for this is when creating a split from a group chat, we have remove the option to deselect users to exclude them. + // We can input '0' next to someone we want to exclude. + if (splitAmount === 0) { + return; + } // In case the participant is a workspace, email & accountID should remain undefined and won't be used in the rest of this code // participant.login is undefined when the request is initiated from a group DM with an unknown user, so we need to add a default @@ -4003,6 +4005,16 @@ function createSplitsAndOnyxData( failureData.push(...oneOnOneFailureData); }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + comment: { + splits: splits.map((split) => ({accountID: split.accountID, amount: split.amount})), + }, + }, + }); + const splitData: SplitData = { chatReportID: splitChatReport.reportID, transactionID: splitTransaction.transactionID, @@ -4036,6 +4048,7 @@ type SplitBillActionsParams = { billable?: boolean; iouRequestType?: IOURequestType; existingSplitChatReportID?: string; + splitShares?: SplitShares; splitPayerAccountIDs?: number[]; }; @@ -4057,6 +4070,7 @@ function splitBill({ billable = false, iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, existingSplitChatReportID = '', + splitShares = {}, splitPayerAccountIDs = [], }: SplitBillActionsParams) { const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); @@ -4071,10 +4085,10 @@ function splitBill({ currentCreated, category, tag, + splitShares, existingSplitChatReportID, billable, iouRequestType, - splitPayerAccountIDs, ); const parameters: SplitBillParams = { @@ -4118,6 +4132,7 @@ function splitBillAndOpenReport({ tag = '', billable = false, iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, + splitShares = {}, splitPayerAccountIDs = [], }: SplitBillActionsParams) { const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); @@ -4132,10 +4147,10 @@ function splitBillAndOpenReport({ currentCreated, category, tag, + splitShares, '', billable, iouRequestType, - splitPayerAccountIDs, ); const parameters: SplitBillParams = { @@ -6381,6 +6396,107 @@ function setShownHoldUseExplanation() { Onyx.set(ONYXKEYS.NVP_HOLD_USE_EXPLAINED, true); } +/** + * Sets the `splitShares` map that holds individual shares of a split bill + */ +function setSplitShares(transaction: OnyxEntry, amount: number, currency: string, newAccountIDs: number[]) { + if (!transaction) { + return; + } + const oldAccountIDs = Object.keys(transaction.splitShares ?? {}).map((key) => Number(key)); + + // Create an array containing unique IDs of the current transaction participants and the new ones + // The current userAccountID might not be included in newAccountIDs if this is called from the participants step using Global Create + // If this is called from an existing group chat, it'll be included. So we manually add them to account for both cases. + const accountIDs = [...new Set([userAccountID, ...newAccountIDs, ...oldAccountIDs])]; + + const splitShares: SplitShares = accountIDs.reduce((result: SplitShares, accountID): SplitShares => { + // We want to replace the contents of splitShares to contain only `newAccountIDs` entries + // In the case of going back to the participants page and removing a participant + // a simple merge will have the previous participant still present in the splitshares object + // So we manually set their entry to null + if (!newAccountIDs.includes(accountID) && accountID !== userAccountID) { + return { + ...result, + [accountID]: null, + }; + } + + const isPayer = accountID === userAccountID; + + // This function expects the length of participants without current user + const splitAmount = IOUUtils.calculateAmount(accountIDs.length - 1, amount, currency, isPayer); + return { + ...result, + [accountID]: { + amount: splitAmount, + isModified: false, + }, + }; + }, {}); + + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, {splitShares}); +} + +function resetSplitShares(transaction: OnyxEntry, newAmount?: number, currency?: string) { + if (!transaction) { + return; + } + const accountIDs = Object.keys(transaction.splitShares ?? {}).map((key) => Number(key)); + if (!accountIDs) { + return; + } + setSplitShares(transaction, newAmount ?? transaction.amount, currency ?? transaction.currency, accountIDs); +} + +/** + * Sets an individual split share of the participant accountID supplied + */ +function setIndividualShare(transactionID: string, participantAccountID: number, participantShare: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + splitShares: { + [participantAccountID]: {amount: participantShare, isModified: true}, + }, + }); +} + +/** + * Adjusts remaining unmodified shares when another share is modified + * E.g. if total bill is $100 and split between 3 participants, when the user changes the first share to $50, the remaining unmodified shares will become $25 each. + */ +function adjustRemainingSplitShares(transaction: NonNullable) { + const modifiedShares = Object.keys(transaction.splitShares ?? {}).filter((key: string) => transaction?.splitShares?.[Number(key)]?.isModified); + + if (!modifiedShares.length) { + return; + } + + const sumOfManualShares = modifiedShares + .map((key: string): number => transaction?.splitShares?.[Number(key)]?.amount ?? 0) + .reduce((prev: number, current: number): number => prev + current, 0); + + const unmodifiedSharesAccountIDs = Object.keys(transaction.splitShares ?? {}) + .filter((key: string) => !transaction?.splitShares?.[Number(key)]?.isModified) + .map((key: string) => Number(key)); + + const remainingTotal = transaction.amount - sumOfManualShares; + if (remainingTotal < 0) { + return; + } + + const splitShares: SplitShares = unmodifiedSharesAccountIDs.reduce((result: SplitShares, accountID: number, index: number): SplitShares => { + const splitAmount = IOUUtils.calculateAmount(unmodifiedSharesAccountIDs.length - 1, remainingTotal, transaction.currency, index === 0); + return { + ...result, + [accountID]: { + amount: splitAmount, + }, + }; + }, {}); + + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, {splitShares}); +} + /** * Put expense on HOLD */ @@ -6581,6 +6697,10 @@ export { setMoneyRequestTaxAmount, setMoneyRequestTaxRate, setShownHoldUseExplanation, + setSplitShares, + resetSplitShares, + setIndividualShare, + adjustRemainingSplitShares, splitBill, splitBillAndOpenReport, startMoneyRequest, diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index a9f096476814..6786198b1dc8 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -182,7 +182,8 @@ function IOURequestStepAmount({ const backendAmount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(amount)); if (shouldSkipConfirmation) { - if (iouType === CONST.IOU.TYPE.SPLIT) { + // Only skip confirmation when the split is not configurable, for now Smartscanned splits cannot be configured + if (iouType === CONST.IOU.TYPE.SPLIT && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN) { IOU.splitBill({ participants, currentUserLogin: currentUserPersonalDetails.login ?? '', @@ -200,6 +201,7 @@ function IOURequestStepAmount({ }); return; } + if (iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.SEND) { if (paymentMethod && paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { IOU.sendMoneyWithWallet(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants[0]); @@ -240,6 +242,10 @@ function IOURequestStepAmount({ } } IOU.setMoneyRequestParticipantsFromReport(transactionID, report); + if (isSplitBill && !report.isOwnPolicyExpenseChat && report.participants) { + const participantAccountIDs = Object.keys(report.participants).map((accountID) => Number(accountID)); + IOU.setSplitShares(transaction, amountInSmallestCurrencyUnits, currency || CONST.CURRENCY.USD, participantAccountIDs); + } navigateToConfirmationPage(); return; } @@ -250,13 +256,18 @@ function IOURequestStepAmount({ }; const saveAmountAndCurrency = ({amount, paymentMethod}: AmountParams) => { + const newAmount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(amount)); + + // Edits to the amount from the splits page should reset the split shares. + if (transaction?.splitShares) { + IOU.resetSplitShares(transaction, newAmount, currency); + } + if (!isEditing) { navigateToNextPage({amount, paymentMethod}); return; } - const newAmount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(amount)); - // If the value hasn't changed, don't request to save changes on the server and just close the modal if (newAmount === TransactionUtils.getAmount(transaction) && currency === TransactionUtils.getCurrency(transaction)) { Navigation.dismissModal(); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index d1c64d355bae..831d58c43434 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -316,6 +316,15 @@ function IOURequestStepConfirmation({ const createTransaction = useCallback( (selectedParticipants: Participant[]) => { + let splitParticipants = selectedParticipants; + + // Filter out participants with an amount equal to O + if (iouType === CONST.IOU.TYPE.SPLIT && transaction?.splitShares) { + const participantsWithAmount = Object.keys(transaction.splitShares ?? {}) + .filter((accountID: string): boolean => (transaction?.splitShares?.[Number(accountID)]?.amount ?? 0) > 0) + .map((accountID) => Number(accountID)); + splitParticipants = selectedParticipants.filter((participant) => participantsWithAmount.includes(participant.accountID ?? -1)); + } const trimmedComment = (transaction?.comment.comment ?? '').trim(); // Don't let the form be submitted multiple times while the navigator is waiting to take the user to a different page @@ -349,7 +358,7 @@ function IOURequestStepConfirmation({ if (iouType === CONST.IOU.TYPE.SPLIT && !transaction?.isFromGlobalCreate) { if (currentUserPersonalDetails.login && !!transaction) { IOU.splitBill({ - participants: selectedParticipants, + participants: splitParticipants, currentUserLogin: currentUserPersonalDetails.login, currentUserAccountID: currentUserPersonalDetails.accountID, amount: transaction.amount, @@ -362,6 +371,7 @@ function IOURequestStepConfirmation({ existingSplitChatReportID: report?.reportID, billable: transaction.billable, iouRequestType: transaction.iouRequestType, + splitShares: transaction.splitShares, splitPayerAccountIDs: transaction.splitPayerAccountIDs ?? [], }); } @@ -372,7 +382,7 @@ function IOURequestStepConfirmation({ if (iouType === CONST.IOU.TYPE.SPLIT) { if (currentUserPersonalDetails.login && !!transaction) { IOU.splitBillAndOpenReport({ - participants: selectedParticipants, + participants: splitParticipants, currentUserLogin: currentUserPersonalDetails.login, currentUserAccountID: currentUserPersonalDetails.accountID, amount: transaction.amount, @@ -384,6 +394,7 @@ function IOURequestStepConfirmation({ tag: transaction.tag, billable: !!transaction.billable, iouRequestType: transaction.iouRequestType, + splitShares: transaction.splitShares, splitPayerAccountIDs: transaction.splitPayerAccountIDs, }); } @@ -563,12 +574,6 @@ function IOURequestStepConfirmation({ iouType={iouType} reportID={reportID} isPolicyExpenseChat={isPolicyExpenseChat} - // The participants can only be modified when the action is initiated from directly within a group chat and not the floating-action-button. - // This is because when there is a group of people, say they are on a trip, and you have some shared expenses with some of the people, - // but not all of them (maybe someone skipped out on dinner). Then it's nice to be able to select/deselect people from the group chat bill - // split rather than forcing the user to create a new group, just for that expense. The reportID is empty, when the action was initiated from - // the floating-action-button (since it is something that exists outside the context of a report). - canModifyParticipants={!transaction?.isFromGlobalCreate} policyID={report?.policyID} bankAccountRoute={ReportUtils.getBankAccountRoute(report)} iouMerchant={transaction?.merchant} diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index f8d18fc03034..bf74b6dca9c8 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -105,6 +105,12 @@ function IOURequestStepParticipants({ const goToNextStep = useCallback(() => { const isCategorizing = action === CONST.IOU.ACTION.CATEGORIZE; + const isPolicyExpenseChat = participants?.some((participant) => participant.isPolicyExpenseChat); + if (iouType === CONST.IOU.TYPE.SPLIT && !isPolicyExpenseChat && transaction?.amount && transaction?.currency) { + const participantAccountIDs = participants?.map((participant) => participant.accountID) as number[]; + IOU.setSplitShares(transaction, transaction.amount, transaction.currency, participantAccountIDs); + } + IOU.setMoneyRequestTag(transactionID, ''); IOU.setMoneyRequestCategory(transactionID, ''); const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, selectedReportID.current || reportID); @@ -113,7 +119,7 @@ function IOURequestStepParticipants({ } else { Navigation.navigate(iouConfirmationPageRoute); } - }, [iouType, transactionID, reportID, action]); + }, [iouType, transactionID, transaction, reportID, action, participants]); const navigateBack = useCallback(() => { IOUUtils.navigateToStartMoneyRequestStep(iouRequestType, iouType, transactionID, reportID, action); diff --git a/src/styles/index.ts b/src/styles/index.ts index 884f241c555f..4555e2e1001b 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1137,9 +1137,13 @@ const styles = (theme: ThemeColors) => borderColor: theme.border, }, - textInputContainerBorder: { - borderBottomWidth: 2, + optionRowAmountInputWrapper: { borderColor: theme.border, + borderBottomWidth: 2, + }, + + optionRowAmountInput: { + textAlign: 'right', }, textInputLabel: { diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index ca238c3ffd88..297636061c95 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1089,6 +1089,44 @@ function getMultiGestureCanvasContainerStyle(canvasWidth: number): ViewStyle { }; } +function percentage(percentageValue: number, totalValue: number) { + return (totalValue / 100) * percentageValue; +} + +/** + * Calculates the width in px of characters from 0 to 9 and '.' + */ +function getCharacterWidth(character: string) { + const defaultWidth = 8; + if (character === '.') { + return percentage(25, defaultWidth); + } + const number = +character; + + // The digit '1' is 62.5% smaller than the default width + if (number === 1) { + return percentage(62.5, defaultWidth); + } + if (number >= 2 && number <= 5) { + return defaultWidth; + } + if (number === 7) { + return percentage(87.5, defaultWidth); + } + if ((number >= 6 && number <= 9) || number === 0) { + return percentage(112.5, defaultWidth); + } + return defaultWidth; +} + +function getAmountWidth(amount: string): number { + let width = 0; + for (let i = 0; i < amount.length; i++) { + width += getCharacterWidth(amount.charAt(i)); + } + return width; +} + const staticStyleUtils = { positioning, combineStyles, @@ -1160,6 +1198,8 @@ const staticStyleUtils = { getSignInBgStyles, getIconWidthAndHeightStyle, getButtonStyleWithIcon, + getCharacterWidth, + getAmountWidth, }; const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ @@ -1624,7 +1664,18 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ * also have an impact on the width of the character, but as long as there's only one font-family and one font-size, * this method will produce reliable results. */ - getCharacterPadding: (prefix: string): number => prefix.length * 10, + getCharacterPadding: (prefix: string): number => { + let padding = 0; + prefix.split('').forEach((char) => { + if (char.match(/[a-z]/i) && char === char.toUpperCase()) { + padding += 11; + } else { + padding += 8; + } + }); + + return padding; + }, // TODO: remove it when we'll implement the callback to handle this toggle in Expensify/Expensify#368335 getWorkspaceWorkflowsOfflineDescriptionStyle: (descriptionTextStyle: TextStyle | TextStyle[]): StyleProp => ({ diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 7894876fdcdf..5ed318b21ce5 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -107,6 +107,13 @@ type TaxRate = { data?: TaxRateData; }; +type SplitShare = { + amount: number; + isModified?: boolean; +}; + +type SplitShares = Record; + type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< { /** The original transaction amount */ @@ -225,6 +232,12 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** Indicates transaction loading */ isLoading?: boolean; + /** Holds individual shares of a split keyed by accountID, only used locally */ + splitShares?: SplitShares; + + /** Holds the accountIDs of accounts who paid the split, for now only supports a single payer */ + splitPayerAccountIDs?: number[]; + /** The actionable report action ID associated with the transaction */ actionableWhisperReportActionID?: string; @@ -233,9 +246,6 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** The linked report id for the tracked expense */ linkedTrackedExpenseReportID?: string; - - /** The payers of split bill transaction */ - splitPayerAccountIDs?: number[]; }, keyof Comment >; @@ -266,4 +276,6 @@ export type { TaxRate, ReceiptSource, TransactionCollectionDataSet, + SplitShare, + SplitShares, };