From 58451ebd7217e4437f4a9e1f2d3268ffc61d0c6e Mon Sep 17 00:00:00 2001 From: Sachin Chavda Date: Fri, 27 Feb 2026 18:23:47 +0530 Subject: [PATCH 01/26] Add beta --- src/CONST/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index dde47a492e87..b4f186fd8cab 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -782,6 +782,7 @@ const CONST = { PERSONAL_CARD_IMPORT: 'personalCardImport', SUGGESTED_FOLLOWUPS: 'suggestedFollowups', FREEZE_CARD: 'freezeCard', + NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', }, BUTTON_STATES: { DEFAULT: 'default', From bc7013171e2bc655318e79114cd569ce19e607aa Mon Sep 17 00:00:00 2001 From: Sachin Chavda Date: Fri, 27 Feb 2026 18:25:16 +0530 Subject: [PATCH 02/26] replace amount pushROw with inline input --- .../MoneyRequestConfirmationList.tsx | 1 + .../MoneyRequestConfirmationListFooter.tsx | 30 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 79b88dd5bf3c..14e2985dba80 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1264,6 +1264,7 @@ function MoneyRequestConfirmationList({ currency={currency} didConfirm={!!didConfirm} distance={distance} + amount={amountToBeUsed} formattedAmount={formattedAmount} formattedAmountPerAttendee={formattedAmountPerAttendee} formError={formError} diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 61810fb55d90..b9d3f8738a44 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOutstandingReports from '@hooks/useOutstandingReports'; +import usePermissions from '@hooks/usePermissions'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrevious from '@hooks/usePrevious'; import useReportAttributes from '@hooks/useReportAttributes'; @@ -20,7 +21,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; +import {convertToDisplayString, convertToFrontendAmountAsString, getCurrencyDecimals, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {isMovingTransactionFromTrackExpense, shouldShowReceiptEmptyState} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -70,6 +71,8 @@ import ReceiptEmptyState from './ReceiptEmptyState'; import ReceiptImage from './ReceiptImage'; import {ShowContextMenuContext} from './ShowContextMenuContext'; import Text from './Text'; +import AmountTextInput from './AmountTextInput'; +import NumberWithSymbolForm from './NumberWithSymbolForm'; type MoneyRequestConfirmationListFooterProps = { /** The action to perform */ @@ -84,6 +87,9 @@ type MoneyRequestConfirmationListFooterProps = { /** The distance of the transaction */ distance: number; + /** The amount of the transaction */ + amount: number; + /** The formatted amount of the transaction */ formattedAmount: string; @@ -259,6 +265,7 @@ function MoneyRequestConfirmationListFooter({ currency, didConfirm, distance, + amount, formattedAmount, formattedAmountPerAttendee, formError, @@ -317,10 +324,15 @@ function MoneyRequestConfirmationListFooter({ const icons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'CalendarSolid', 'Sparkles', 'DownArrow']); 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 [transactionAmount, setTransactionAmount] = useState(convertToFrontendAmountAsString(amount, currency)); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); @@ -487,7 +499,19 @@ function MoneyRequestConfirmationListFooter({ const fields: ConfirmationField[] = [ { - item: ( + item: isNewManualExpenseFlowEnabled ? ( + + + + ) : ( Date: Mon, 9 Mar 2026 09:52:40 +0530 Subject: [PATCH 03/26] IOU confirmation - amount - replace push row with inline input --- src/components/AmountTextInput.tsx | 5 + .../MoneyRequestConfirmationList.tsx | 49 +++++++- .../MoneyRequestConfirmationListFooter.tsx | 119 +++++++++++++++--- src/components/NumberWithSymbolForm.tsx | 62 ++++++++- .../implementation/index.native.tsx | 6 + .../BaseTextInput/implementation/index.tsx | 7 +- .../TextInput/BaseTextInput/types.ts | 3 + .../BaseTextInputWithSymbol.tsx | 2 + src/components/TextInputWithSymbol/types.ts | 3 + .../step/IOURequestStepConfirmation.tsx | 25 +++- 10 files changed, 251 insertions(+), 30 deletions(-) diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 05c58733490b..55b82b5acb2e 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -46,6 +46,9 @@ type AmountTextInputProps = { /** A unique identifier for this text input for testing purposes */ testID?: string; + + /** 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({ @@ -64,6 +67,7 @@ function AmountTextInput({ ref, disabled, accessibilityLabel, + rightHandSideComponent, ...rest }: AmountTextInputProps) { const navigation = useNavigation(); @@ -99,6 +103,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 14e2985dba80..fe9dffa0f1ea 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -86,7 +86,7 @@ import EducationalTooltip from './Tooltip/EducationalTooltip'; type MoneyRequestConfirmationListProps = { /** Callback to inform parent modal of success */ - onConfirm?: (selectedParticipants: Participant[]) => void; + onConfirm?: (selectedParticipants: Participant[], amount?: number, currency?: string) => void; /** Callback to parent modal to pay someone */ onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void; @@ -284,6 +284,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(); @@ -402,6 +403,20 @@ function MoneyRequestConfirmationList({ const [didConfirmSplit, setDidConfirmSplit] = useState(false); const [showMoreFields, setShowMoreFields] = useState(false); + // For the new manual expense flow (beta), track the latest amount/currency the user has typed + // using refs rather than state to avoid re-rendering 20+ Onyx-watching components on every + // keystroke. The refs are only read once on final submission to persist the value to Onyx. + const pendingAmountRef = useRef(null); + const pendingCurrencyRef = useRef(null); + + // Callbacks passed to the footer to capture the user's edits without triggering re-renders + const handleAmountChange = useCallback((value: number | null) => { + pendingAmountRef.current = value; + }, []); + const handleCurrencyChange = useCallback((value: string) => { + pendingCurrencyRef.current = value; + }, []); + useEffect(() => { setShowMoreFields(false); }, [transactionID]); @@ -582,6 +597,11 @@ function MoneyRequestConfirmationList({ } else { text = translate('common.next'); } + } else if (isNewManualExpenseFlowEnabled) { + // In the new manual expense flow (beta), the user edits the amount inline so the + // button always reads "Create expense" with no amount — this prevents the button + // from re-rendering on every keystroke and avoids showing a stale/incorrect value. + text = translate('iou.createExpense'); } else if (isTypeTrackExpense) { text = translate('iou.createExpense'); if (iouAmount !== 0) { @@ -621,6 +641,7 @@ function MoneyRequestConfirmationList({ policy, translate, formattedAmount, + isNewManualExpenseFlowEnabled, ]); const onSplitShareChange = useCallback( @@ -978,6 +999,15 @@ function MoneyRequestConfirmationList({ setFormError('iou.error.noParticipantSelected'); return; } + + const amountForValidation = isNewManualExpenseFlowEnabled ? pendingAmountRef.current : 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; @@ -1064,7 +1094,12 @@ function MoneyRequestConfirmationList({ return; } - onConfirm?.(selectedParticipants); + // For the new manual expense flow (beta) the user edits amount/currency inline. + // Pass the pending values directly through the callback so the caller can use + // them immediately — bypassing the Onyx → React state propagation delay that + // would otherwise cause requestMoney to read the stale draft value. + // Non-beta flows leave the refs as null, so the caller falls back to item.amount. + onConfirm?.(selectedParticipants, pendingAmountRef.current ?? undefined, pendingCurrencyRef.current ?? undefined); } else { if (!paymentMethod) { return; @@ -1100,6 +1135,7 @@ function MoneyRequestConfirmationList({ iouCurrencyCode, isDistanceRequest, isDistanceRequestWithPendingRoute, + isNewManualExpenseFlowEnabled, iouAmount, formError, onConfirm, @@ -1230,8 +1266,13 @@ function MoneyRequestConfirmationList({ iouType, confirm, iouCurrencyCode, + isDistanceRequest, + currency, + formattedAmount, policyID, + reportID, isConfirmed, + isConfirming, splitOrRequestOptions, errorMessage, expensesNumber, @@ -1243,8 +1284,6 @@ function MoneyRequestConfirmationList({ styles.productTrainingTooltipWrapper, shouldShowProductTrainingTooltip, renderProductTrainingTooltip, - isConfirming, - reportID, ]); const isCompactMode = useMemo(() => !showMoreFields && isScanRequest, [isScanRequest, showMoreFields]); @@ -1319,6 +1358,8 @@ function MoneyRequestConfirmationList({ isDescriptionRequired={isDescriptionRequired} showMoreFields={showMoreFields} setShowMoreFields={setShowMoreFields} + onAmountChange={handleAmountChange} + onCurrencyChange={handleCurrencyChange} /> ); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index b9d3f8738a44..c3132a0e55d9 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -21,7 +21,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {convertToDisplayString, convertToFrontendAmountAsString, getCurrencyDecimals, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; +import {convertToBackendAmount, convertToDisplayString, convertToFrontendAmountAsString, getCurrencyDecimals, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {isMovingTransactionFromTrackExpense, shouldShowReceiptEmptyState} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -30,7 +30,7 @@ import {canSendInvoice, getPerDiemCustomUnit} from '@libs/PolicyUtils'; import type {ThumbnailAndImageURI} from '@libs/ReceiptUtils'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {getReportName} from '@libs/ReportNameUtils'; -import {generateReportID, getDefaultWorkspaceAvatar, getOutstandingReportsForUser, isMoneyRequestReport, isReportOutstanding} from '@libs/ReportUtils'; +import {generateReportID, getDefaultWorkspaceAvatar, getOutstandingReportsForUser, isMoneyRequestReport, isReportOutstanding, shouldEnableNegative} from '@libs/ReportUtils'; import {getTagVisibility, hasEnabledTags} from '@libs/TagsOptionsListUtils'; import { getTagForDisplay, @@ -45,6 +45,7 @@ import { willFieldBeAutomaticallyFilled, } from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; +import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -65,14 +66,13 @@ import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import getCompactReceiptDimensions from './MoneyRequestConfirmationListFooter/getCompactReceiptDimensions'; import getImageCompactModeStyle from './MoneyRequestConfirmationListFooter/getImageCompactModeStyle'; import getReceiptContainerCompactModeStyle from './MoneyRequestConfirmationListFooter/getReceiptContainerCompactModeStyle'; +import NumberWithSymbolForm from './NumberWithSymbolForm'; import PDFThumbnail from './PDFThumbnail'; import PressableWithoutFocus from './Pressable/PressableWithoutFocus'; import ReceiptEmptyState from './ReceiptEmptyState'; import ReceiptImage from './ReceiptImage'; import {ShowContextMenuContext} from './ShowContextMenuContext'; import Text from './Text'; -import AmountTextInput from './AmountTextInput'; -import NumberWithSymbolForm from './NumberWithSymbolForm'; type MoneyRequestConfirmationListFooterProps = { /** The action to perform */ @@ -87,7 +87,7 @@ type MoneyRequestConfirmationListFooterProps = { /** The distance of the transaction */ distance: number; - /** The amount of the transaction */ + /** The amount of the transaction */ amount: number; /** The formatted amount of the transaction */ @@ -223,7 +223,7 @@ type MoneyRequestConfirmationListFooterProps = { transaction: OnyxEntry; /** The transaction ID */ - transactionID: string | undefined; + transactionID?: string; /** Whether the receipt can be replaced */ isReceiptEditable?: boolean; @@ -251,6 +251,12 @@ type MoneyRequestConfirmationListFooterProps = { /** Toggles compact mode by showing all fields */ setShowMoreFields?: (showMoreFields: boolean) => void; + + /** Callback when amount changes in the input */ + onAmountChange?: (amount: number | null) => void; + + /** Callback when currency changes */ + onCurrencyChange?: (currency: string) => void; }; type ConfirmationField = { @@ -259,7 +265,6 @@ type ConfirmationField = { shouldShowAboveShowMore?: boolean; isSupplementary?: boolean; }; - function MoneyRequestConfirmationListFooter({ action, currency, @@ -310,7 +315,7 @@ function MoneyRequestConfirmationListFooter({ shouldShowAmountField = true, shouldShowTax, transaction, - transactionID, + transactionID = '-1', unit, onPDFLoadError, onPDFPassword, @@ -320,22 +325,27 @@ function MoneyRequestConfirmationListFooter({ isDescriptionRequired = false, showMoreFields = false, setShowMoreFields = () => {}, + onAmountChange, + onCurrencyChange, }: 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, preferredLocale} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); const {isOffline} = useNetwork(); const {windowWidth} = useWindowDimensions(); - + const {isBetaEnabled} = usePermissions(); const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); + // Local state for the new manual expense flow const [transactionAmount, setTransactionAmount] = useState(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); @@ -409,6 +419,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. @@ -497,18 +509,80 @@ function MoneyRequestConfirmationListFooter({ return ''; }, [transaction, translate, isCategoryRequired]); + const allowNegative = shouldEnableNegative(report, policy, iouType, transaction?.participants); + // Track selected currency separately to allow changing it in the new manual flow + const [selectedCurrency, setSelectedCurrency] = useState(currency); + const decimals = getCurrencyDecimals(selectedCurrency || currency); + + const showCurrencyPicker = useCallback(() => { + setIsCurrencyPickerVisible(true); + }, []); + + const hideCurrencyPicker = useCallback(() => { + setIsCurrencyPickerVisible(false); + }, []); + + /** + * Updates the selected currency for the transaction. + * Updates local display state and stores the value in the parent ref for use at submission. + */ + const updateSelectedCurrency = useCallback( + (value: string) => { + setSelectedCurrency(value); + hideCurrencyPicker(); + onCurrencyChange?.(value); + }, + [hideCurrencyPicker, onCurrencyChange], + ); + + /** + * Handles amount changes in the new manual expense flow. + * Updates local display state and stores the backend amount (cents) in the parent ref + * so it can be used at submission time without triggering Onyx/re-render on every keystroke. + */ + const handleAmountChange = useCallback( + (newAmount: string) => { + setTransactionAmount(newAmount); + + const isNegative = newAmount.startsWith('-'); + const cleanAmount = newAmount.replace('-', ''); + + if (!cleanAmount || cleanAmount.trim() === '') { + onAmountChange?.(null); + return; + } + + const numericAmount = Number.parseFloat(cleanAmount); + if (!Number.isNaN(numericAmount)) { + const absoluteBackendAmount = convertToBackendAmount(numericAmount); + onAmountChange?.(isNegative ? -absoluteBackendAmount : absoluteBackendAmount); + } + }, + [onAmountChange], + ); + + const shouldShowAmountRequiredError = useMemo(() => { + return formError === 'common.error.invalidAmount'; + }, [formError, transactionAmount]); + const fields: ConfirmationField[] = [ { item: isNewManualExpenseFlowEnabled ? ( - - + ) : ( @@ -1195,6 +1269,13 @@ function MoneyRequestConfirmationListFooter({ return ( + {isTypeInvoice && ( void; + + /** 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 = { @@ -153,6 +162,9 @@ function NumberWithSymbolForm({ ref, disabled, onSubmitEditing, + shouldShowFlipButton = false, + shouldShowCurrencyButton = false, + onCurrencyButtonPress, ...props }: NumberWithSymbolFormProps) { const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'PlusMinus']); @@ -372,6 +384,53 @@ function NumberWithSymbolForm({ const formattedNumber = replaceAllDigits(currentNumber, toLocaleDigit); + /** + * Handles pressing the flip button (+/-) to toggle negative sign + * Only available in displayAsTextInput mode for manual expense flow + */ + const handleFlipPress = useCallback(() => { + if (!currentNumber) { + return; + } + + // 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 && ( +