diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b119c440ba46f..ff2b0c70e0c55 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -781,6 +781,7 @@ const CONST = { PERSONAL_CARD_IMPORT: 'personalCardImport', SUGGESTED_FOLLOWUPS: 'suggestedFollowups', FREEZE_CARD: 'freezeCard', + NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 8ae2dbbcc7ff3..9461f2f5872fd 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -49,6 +49,9 @@ type AmountTextInputProps = { /** Determines which keyboard to open */ keyboardType?: KeyboardTypeOptions; + + /** Component to render on the right hand side of the input - only shown if clear button is not rendered */ + rightHandSideComponent?: React.ReactNode; } & Pick; function AmountTextInput({ @@ -67,6 +70,7 @@ function AmountTextInput({ ref, disabled, accessibilityLabel, + rightHandSideComponent, ...rest }: AmountTextInputProps) { const navigation = useNavigation(); @@ -102,6 +106,7 @@ function AmountTextInput({ disableKeyboardShortcuts shouldUseFullInputHeight shouldApplyPaddingToContainer={shouldApplyPaddingToContainer} + rightHandSideComponent={rightHandSideComponent} navigation={navigation} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index c5fc3b3280dfc..99cb0f678d952 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -289,6 +289,7 @@ function MoneyRequestConfirmationList({ const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); + const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -385,7 +386,6 @@ function MoneyRequestConfirmationList({ const distance = getDistanceInMeters(transaction, unit); const prevDistance = usePrevious(distance); - const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit); const shouldCalculatePerDiemAmount = isPerDiemRequest && (iouAmount === 0 || JSON.stringify(prevSubRates) !== JSON.stringify(subRates) || prevCurrency !== currency); @@ -598,20 +598,22 @@ function MoneyRequestConfirmationList({ } } else if (isTypeTrackExpense) { text = translate('iou.createExpense'); - if (iouAmount !== 0) { + if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) { text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); } } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.splitExpense'); } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute || isPerDiemRequest) { text = translate('iou.createExpense'); - if (iouAmount !== 0) { + if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) { text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); } } else if (isTypeSplit) { text = translate('iou.splitAmount', formattedAmount); } else if (iouAmount === 0) { text = translate('iou.createExpense'); + } else if (isNewManualExpenseFlowEnabled) { + text = translate('iou.createExpense'); } else { text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); } @@ -635,6 +637,7 @@ function MoneyRequestConfirmationList({ policy, translate, formattedAmount, + isNewManualExpenseFlowEnabled, ]); const onSplitShareChange = useCallback( @@ -994,6 +997,15 @@ function MoneyRequestConfirmationList({ setFormError('iou.error.noParticipantSelected'); return; } + + const amountForValidation = iouAmount; + const isAmountMissingForManualFlow = amountForValidation === null || amountForValidation === undefined; + + if (iouType !== CONST.IOU.TYPE.PAY && isNewManualExpenseFlowEnabled && isAmountMissingForManualFlow) { + setFormError('common.error.invalidAmount'); + return; + } + if (!isEditingSplitBill && isMerchantRequired && (isMerchantEmpty || (shouldDisplayFieldError && isMerchantMissing(transaction)))) { setFormError('iou.error.invalidMerchant'); return; @@ -1252,7 +1264,9 @@ function MoneyRequestConfirmationList({ confirm, iouCurrencyCode, policyID, + reportID, isConfirmed, + isConfirming, splitOrRequestOptions, errorMessage, expensesNumber, @@ -1264,8 +1278,6 @@ function MoneyRequestConfirmationList({ styles.productTrainingTooltipWrapper, shouldShowProductTrainingTooltip, renderProductTrainingTooltip, - isConfirming, - reportID, ]); const isCompactMode = useMemo(() => !showMoreFields && isScanRequest, [isScanRequest, showMoreFields]); @@ -1282,9 +1294,10 @@ function MoneyRequestConfirmationList({ {}, }: MoneyRequestConfirmationListFooterProps) { - const icons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'CalendarSolid', 'Sparkles', 'DownArrow']); + const icons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'CalendarSolid', 'Sparkles', 'DownArrow', 'PlusMinus']); const styles = useThemeStyles(); const theme = useTheme(); - const {translate, toLocaleDigit, localeCompare} = useLocalize(); + const {translate, toLocaleDigit, localeCompare, preferredLocale} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); const {isOffline} = useNetwork(); const {windowWidth} = useWindowDimensions(); + const {isBetaEnabled} = usePermissions(); + const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); + + const currency = isDistanceRequest ? distanceRateCurrency : (iouCurrencyCode ?? CONST.CURRENCY.USD); // TO DO: Unify currency source once we remove the old flow + const decimals = getCurrencyDecimals(currency); + const transactionAmount = convertToFrontendAmountAsString(amount, currency); + const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const reportAttributes = useReportAttributes(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); @@ -402,6 +418,8 @@ function MoneyRequestConfirmationListFooter({ return name; }, [isUnreported, selectedReport, reportAttributes, translate]); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const outstandingReports = useOutstandingReports(undefined, isFromGlobalCreate && !isPerDiemRequest ? undefined : policyID, ownerAccountID, false); // When creating an expense in an individual report, the report field becomes read-only // since the destination is already determined and there's no need to show a selectable list. @@ -498,32 +516,133 @@ function MoneyRequestConfirmationListFooter({ return ''; }, [transaction, translate, isCategoryRequired]); + const allowNegative = shouldEnableNegative(report, policy, iouType, transaction?.participants); + + const showCurrencyPicker = useCallback(() => { + setIsCurrencyPickerVisible(true); + }, []); + + const hideCurrencyPicker = useCallback(() => { + setIsCurrencyPickerVisible(false); + }, []); + + const getBackendAmountFromInput = useCallback((value: string) => { + const isNegative = value.startsWith('-'); + const cleanAmount = value.replace('-', ''); + + if (!cleanAmount || cleanAmount.trim() === '') { + return null; + } + + const numericAmount = Number.parseFloat(cleanAmount); + if (Number.isNaN(numericAmount)) { + return null; + } + + const absoluteBackendAmount = convertToBackendAmount(numericAmount); + return isNegative ? -absoluteBackendAmount : absoluteBackendAmount; + }, []); + + /** + * Updates the selected currency for the transaction. + * Updates local display state and persists the value to draft transaction. + */ + const updateCurrency = useCallback( + (value: string) => { + hideCurrencyPicker(); + + if (!transactionID) { + return; + } + + const parsedAmount = getBackendAmountFromInput(transactionAmount); + setMoneyRequestAmount(transactionID, parsedAmount ?? amount, value); + }, + [hideCurrencyPicker, transactionID, getBackendAmountFromInput, transactionAmount, amount], + ); + + /** + * Handles amount changes in the new manual expense flow. + * Updates local display state and persists the backend amount (cents) in transaction draft. + */ + const handleAmountChange = useCallback( + (newAmount: string) => { + if (!transactionID) { + return; + } + + const parsedAmount = getBackendAmountFromInput(newAmount); + if (parsedAmount === null) { + return; + } + + setMoneyRequestAmount(transactionID, parsedAmount, currency); + }, + [transactionID, getBackendAmountFromInput, currency], + ); + + const shouldShowAmountRequiredError = useMemo(() => { + return formError === 'common.error.invalidAmount'; + }, [formError]); + + const isAmountFieldDisabled = useMemo(() => { + return didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest; + }, [didConfirm, isReadOnly, shouldShowTimeRequestFields, isDistanceRequest]); + const fields: ConfirmationField[] = [ { - item: ( - { - if (isDistanceRequest || shouldShowTimeRequestFields || !transactionID) { - return; - } + item: + isNewManualExpenseFlowEnabled && !isAmountFieldDisabled ? ( + + + + ) : ( + { + if (isDistanceRequest || shouldShowTimeRequestFields || !transactionID) { + return; + } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(action, iouType, transactionID, reportID, reportActionID, CONST.IOU.PAGE_INDEX.CONFIRM, Navigation.getActiveRoute()), - ); - }} - style={[styles.moneyRequestMenuItem, styles.mt2]} - titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm} - brickRoadIndicator={shouldDisplayFieldError && isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={shouldDisplayFieldError && isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} - sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.AMOUNT_FIELD} - /> - ), + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( + action, + iouType, + transactionID, + reportID, + reportActionID, + CONST.IOU.PAGE_INDEX.CONFIRM, + Navigation.getActiveRoute(), + ), + ); + }} + style={[styles.moneyRequestMenuItem, styles.mt2]} + titleStyle={styles.moneyRequestConfirmationAmount} + disabled={didConfirm} + brickRoadIndicator={shouldDisplayFieldError && isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={shouldDisplayFieldError && isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''} + sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.AMOUNT_FIELD} + /> + ), shouldShow: shouldShowSmartScanFields && shouldShowAmountField, shouldShowAboveShowMore: false, }, @@ -605,7 +724,7 @@ function MoneyRequestConfirmationListFooter({ shouldShowRightIcon={isRateInteractive} // Pass false for isCustomUnitOutOfPolicy because this is the expense creation/edit // confirmation screen where a rate violation is not applicable yet. - title={DistanceRequestUtils.getRateForExpenseDisplay(distanceRateName, false, unit, rate, currency, translate, toLocaleDigit, getCurrencySymbol, isOffline)} + title={DistanceRequestUtils.getRateForExpenseDisplay(distanceRateName, false, unit, rate, distanceRateCurrency, translate, toLocaleDigit, getCurrencySymbol, isOffline)} description={translate('common.rate')} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} @@ -1195,6 +1314,13 @@ function MoneyRequestConfirmationListFooter({ return ( + {isTypeInvoice && ( prevProps.action === nextProps.action && - prevProps.currency === nextProps.currency && + prevProps.distanceRateCurrency === nextProps.distanceRateCurrency && prevProps.didConfirm === nextProps.didConfirm && prevProps.distance === nextProps.distance && prevProps.formattedAmount === nextProps.formattedAmount && diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx index 25d480ff6a294..98d1815b44637 100644 --- a/src/components/NumberWithSymbolForm.tsx +++ b/src/components/NumberWithSymbolForm.tsx @@ -97,6 +97,15 @@ type NumberWithSymbolFormProps = { /** Determines which keyboard to open */ keyboardType?: KeyboardTypeOptions; + + /** Whether to show the flip (+/-) button */ + shouldShowFlipButton?: boolean; + + /** Whether to show the currency selection button */ + shouldShowCurrencyButton?: boolean; + + /** Callback when currency button is pressed */ + onCurrencyButtonPress?: () => void; } & Omit; type NumberWithSymbolFormRef = { @@ -161,6 +170,9 @@ function NumberWithSymbolForm({ ref, disabled, onSubmitEditing, + shouldShowFlipButton = false, + shouldShowCurrencyButton = false, + onCurrencyButtonPress, ...props }: NumberWithSymbolFormProps) { const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'PlusMinus']); @@ -388,6 +400,52 @@ function NumberWithSymbolForm({ return StyleUtils.getAmountInputFontSize(totalLength); }, [StyleUtils, formattedNumber.length, hideSymbol, symbol.length, isNegative]); + /** + * Handles pressing the flip button (+/-) to toggle negative sign + * Only available in displayAsTextInput mode for manual expense flow + */ + const handleFlipPress = useCallback(() => { + // Toggle the minus sign prefix in the value + const newValue = currentNumber.startsWith('-') ? currentNumber.slice(1) : `-${currentNumber}`; + setCurrentNumber(newValue); + onInputChange?.(newValue); + }, [currentNumber, onInputChange]); + + /** + * Creates the right-hand side component for text input mode + * Renders flip (+/-) button and/or currency selection button when enabled + * Only shown when clear button is not visible (see TextInput conditional rendering) + */ + const textInputRightHandSideComponent = useMemo(() => { + return ( + + {shouldShowFlipButton && canUseTouchScreen && ( +