diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 092dd7486741..5963bc39672d 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1063,6 +1063,7 @@ const CONST = { SHUTTER_SIZE: 90, MAX_REPORT_PREVIEW_RECEIPTS: 3, }, + RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { ROLE: { ADMIN: 'admin', @@ -1541,6 +1542,9 @@ const CONST = { SKELETON_ANIMATION_SPEED: 3, SEARCH_MOST_RECENT_OPTIONS: 'search_most_recent_options', DEBOUNCE_HANDLE_SEARCH: 'debounce_handle_search', + FAST_SEARCH_TREE_CREATION: 'fast_search_tree_creation', + SHOW_HOVER_PREVIEW_DELAY: 270, + SHOW_HOVER_PREVIEW_ANIMATION_DURATION: 250, }, PRIORITY_MODE: { GSD: 'gsd', diff --git a/src/components/DistanceEReceipt.tsx b/src/components/DistanceEReceipt.tsx index 8de8546150a6..4470ca193dfd 100644 --- a/src/components/DistanceEReceipt.tsx +++ b/src/components/DistanceEReceipt.tsx @@ -22,9 +22,12 @@ import Text from './Text'; type DistanceEReceiptProps = { /** The transaction for the distance expense */ transaction: Transaction; + + /** Whether the distanceEReceipt is shown as hover preview */ + hoverPreview?: boolean; }; -function DistanceEReceipt({transaction}: DistanceEReceiptProps) { +function DistanceEReceipt({transaction, hoverPreview = false}: DistanceEReceiptProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const thumbnail = hasReceipt(transaction) ? getThumbnailAndImageURIs(transaction).thumbnail : null; @@ -42,7 +45,7 @@ function DistanceEReceipt({transaction}: DistanceEReceiptProps) { [waypoints], ); return ( - + {transactionAmount !== null && transactionAmount !== undefined && {formattedTransactionAmount}} - {transactionMerchant} + {transactionMerchant === translate('iou.fieldPending') ? transaction.merchant : transactionMerchant} {Object.entries(sortedWaypoints).map(([key, waypoint]) => { diff --git a/src/components/EReceipt.tsx b/src/components/EReceipt.tsx index e9fe5bf1ceef..b85ccd771f00 100644 --- a/src/components/EReceipt.tsx +++ b/src/components/EReceipt.tsx @@ -55,7 +55,6 @@ function EReceipt({transactionID, transactionItem, isThumbnail = false}: EReceip const currency = getCurrencySymbol(transactionCurrency ?? ''); const amount = currency ? formattedAmount.replace(currency, '') : formattedAmount; const cardDescription = getCompanyCardDescription(transactionCardName, transactionCardID, cardList) ?? (transactionCardID ? getCardDescription(cardList?.[transactionCardID]) : ''); - const secondaryBgcolorStyle = secondaryColor ? StyleUtils.getBackgroundColorStyle(secondaryColor) : undefined; const primaryTextColorStyle = primaryColor ? StyleUtils.getColorStyle(primaryColor) : undefined; const titleTextColorStyle = titleColor ? StyleUtils.getColorStyle(titleColor) : undefined; diff --git a/src/components/EReceiptWithSizeCalculation.tsx b/src/components/EReceiptWithSizeCalculation.tsx index 411b99cf7dfb..7c6ff2cc1616 100644 --- a/src/components/EReceiptWithSizeCalculation.tsx +++ b/src/components/EReceiptWithSizeCalculation.tsx @@ -13,13 +13,15 @@ type EReceiptWithSizeCalculationProps = { transactionItem?: TransactionListItemType | Transaction; }; +const eReceiptAspectRatio = variables.eReceiptBGHWidth / variables.eReceiptBGHeight; + function EReceiptWithSizeCalculation(props: EReceiptWithSizeCalculationProps) { const [scaleFactor, setScaleFactor] = useState(0); const styles = useThemeStyles(); const onLayout = (e: LayoutChangeEvent) => { const {width} = e.nativeEvent.layout; - setScaleFactor(width / variables.eReceiptBGHWidth); + setScaleFactor(width / variables.eReceiptBGWidth); }; return scaleFactor ? ( @@ -28,7 +30,11 @@ function EReceiptWithSizeCalculation(props: EReceiptWithSizeCalculationProps) { onLayout={onLayout} // We are applying transform of 0 translateZ to avoid a sub-pixel rendering error of a thin 1px line // appearing on EReceipts on web, specifically in chrome. More details in https://github.com/Expensify/App/pull/59944#issuecomment-2797249923. - style={[styles.w100, styles.h100, {transform: `scale(${scaleFactor}) ${styles.translateZ0.transform as string}`, transformOrigin: 'top left'}]} + style={[ + styles.w100, + styles.h100, + {transform: `scale(${scaleFactor}) ${styles.translateZ0.transform as string}`, transformOrigin: 'top left', aspectRatio: eReceiptAspectRatio}, + ]} > + ); diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.native.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.native.tsx new file mode 100644 index 000000000000..eb62a4c48e37 --- /dev/null +++ b/src/components/TransactionItemRow/ReceiptPreview/index.native.tsx @@ -0,0 +1,5 @@ +function ReceiptPreview() { + return null; +} + +export default ReceiptPreview; diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.tsx new file mode 100644 index 000000000000..ea39ae27cfa0 --- /dev/null +++ b/src/components/TransactionItemRow/ReceiptPreview/index.tsx @@ -0,0 +1,169 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import ReactDOM from 'react-dom'; +import type {LayoutChangeEvent} from 'react-native'; +import {ActivityIndicator, StyleSheet, View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import DistanceEReceipt from '@components/DistanceEReceipt'; +import EReceiptWithSizeCalculation from '@components/EReceiptWithSizeCalculation'; +import type {ImageOnLoadEvent} from '@components/Image/types'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {isDistanceRequest} from '@libs/TransactionUtils'; +import variables from '@styles/variables'; +import Image from '@src/components/Image'; +import CONST from '@src/CONST'; +import type {Transaction} from '@src/types/onyx'; + +type ReceiptPreviewProps = { + /** Path to the image to be opened in the preview */ + source: string; + + /** Whether the preview should be shown (e.g. if we are hovered over certain ReceiptCell) */ + hovered: boolean; + + /** Is preview for an e-receipt */ + isEReceipt: boolean; + + /** Transaction object related to the preview */ + transactionItem: Transaction; +}; + +function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem}: ReceiptPreviewProps) { + const isDistanceEReceipt = isDistanceRequest(transactionItem); + const styles = useThemeStyles(); + const theme = useTheme(); + const [eReceiptScaleFactor, setEReceiptScaleFactor] = useState(0); + const [imageAspectRatio, setImageAspectRatio] = useState(undefined); + const [distanceEReceiptAspectRatio, setDistanceEReceiptAspectRatio] = useState(undefined); + const [shouldShow, debounceShouldShow, setShouldShow] = useDebouncedState(false, CONST.TIMING.SHOW_HOVER_PREVIEW_DELAY); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const hasMeasured = useRef(false); + const {windowHeight} = useWindowDimensions(); + const [isLoading, setIsLoading] = useState(true); + + const handleDistanceEReceiptLayout = (e: LayoutChangeEvent) => { + if (hasMeasured.current) { + return; + } + hasMeasured.current = true; + + const {height, width} = e.nativeEvent.layout; + if (height === 0) { + // on the initial layout, measured height is 0, so we want to set everything on the second one + hasMeasured.current = false; + return; + } + if (height * eReceiptScaleFactor >= windowHeight - CONST.RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN) { + setDistanceEReceiptAspectRatio(variables.eReceiptBGHWidth / (windowHeight - CONST.RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN)); + setEReceiptScaleFactor(width / variables.eReceiptBGHWidth); + return; + } + setDistanceEReceiptAspectRatio(variables.eReceiptBGHWidth / height); + setEReceiptScaleFactor(width / variables.eReceiptBGHWidth); + }; + + const updateImageAspectRatio = useCallback( + (width: number, height: number) => { + if (!source) { + return; + } + + setImageAspectRatio(height ? width / height : 'auto'); + }, + [source], + ); + + const handleLoad = useCallback( + (e: ImageOnLoadEvent) => { + const {width, height} = e.nativeEvent; + setIsLoading(false); + updateImageAspectRatio(width, height); + }, + [updateImageAspectRatio], + ); + + const handleError = () => { + setIsLoading(false); + }; + + useEffect(() => { + setShouldShow(hovered); + }, [hovered, setShouldShow]); + + if (shouldUseNarrowLayout || !debounceShouldShow || (!source && !isEReceipt && !isDistanceEReceipt)) { + return null; + } + + const shouldShowImage = source && !(isEReceipt || isDistanceEReceipt); + const shouldShowDistanceEReceipt = isDistanceEReceipt && !isEReceipt; + + return ReactDOM.createPortal( + + {shouldShowImage ? ( + + {isLoading && ( + + + + )} + + { + if (isLoading) { + return; + } + setIsLoading(true); + }} + onError={handleError} + onLoad={handleLoad} + isAuthTokenRequired + /> + + ) : ( + + {shouldShowDistanceEReceipt ? ( + + + + ) : ( + + )} + + )} + , + document.body, + ); +} + +export default ReceiptPreview; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 5e7d755eedf2..c2135989b9ac 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -903,7 +903,7 @@ function getTransactionsSections( isAmountColumnWide: shouldShowAmountInWideColumn, isTaxAmountColumnWide: shouldShowTaxAmountInWideColumn, violations: transactionViolations, - + filename: transactionItem.filename, // Manually copying all the properties from transactionItem transactionID: transactionItem.transactionID, created: transactionItem.created, @@ -941,6 +941,8 @@ function getTransactionsSections( errors: transactionItem.errors, isActionLoading: transactionItem.isActionLoading, hasViolation: transactionItem.hasViolation, + cardID: transactionItem.cardID, + cardName: transactionItem.cardName, }; transactionsSections.push(transactionSection); diff --git a/src/styles/index.ts b/src/styles/index.ts index 8f3f5f4b0ee2..9e854ba7fc82 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5731,6 +5731,26 @@ const styles = (theme: ThemeColors) => aspectRatio: 1.7, }, + receiptPreview: { + position: 'fixed', + left: 60, + top: 60, + width: 380, + maxHeight: 'calc(100vh - 120px)', + borderRadius: variables.componentBorderRadiusLarge, + borderWidth: 1, + borderColor: theme.border, + overflow: 'hidden', + boxShadow: theme.shadow, + backgroundColor: theme.appBG, + }, + + receiptPreviewEReceiptsContainer: { + ...sizing.w100, + ...sizing.h100, + backgroundColor: colors.green800, + }, + topBarWrapper: { zIndex: 15, }, diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 8535dfaf722a..17557d7b3199 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -400,6 +400,9 @@ type SearchTransaction = { /** The ID of the report the transaction is associated with */ reportID: string; + /** The name of the file used for a receipt */ + filename?: string; + /** The report ID of the transaction thread associated with the transaction */ transactionThreadReportID: string; @@ -426,6 +429,12 @@ type SearchTransaction = { /** The type of action that's pending */ pendingAction?: OnyxCommon.PendingAction; + + /** The CC for this transaction */ + cardID?: number; + + /** The display name of the purchaser card, if any */ + cardName?: string; }; /** Model of tasks search result */ diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 08d44333a777..4031c018c855 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -279,6 +279,8 @@ const searchResults: OnyxTypes.SearchResults = { canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: { comment: '', @@ -310,6 +312,7 @@ const searchResults: OnyxTypes.SearchResults = { modifiedMCCGroup: undefined, moneyRequestReportActionID: undefined, errors: undefined, + filename: undefined, isActionLoading: false, }, [`transactions_${transactionID2}`]: { @@ -319,6 +322,8 @@ const searchResults: OnyxTypes.SearchResults = { canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: { comment: '', @@ -350,6 +355,7 @@ const searchResults: OnyxTypes.SearchResults = { moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, }, ...allViolations, @@ -360,6 +366,8 @@ const searchResults: OnyxTypes.SearchResults = { canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: { comment: '', @@ -390,6 +398,7 @@ const searchResults: OnyxTypes.SearchResults = { moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: undefined, }, @@ -400,6 +409,8 @@ const searchResults: OnyxTypes.SearchResults = { canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: { comment: '', @@ -430,6 +441,7 @@ const searchResults: OnyxTypes.SearchResults = { moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: undefined, }, @@ -593,6 +605,8 @@ const transactionsListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2024-12-21', @@ -646,6 +660,7 @@ const transactionsListItems = [ modifiedMCCGroup: undefined, moneyRequestReportActionID: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: false, violations: [], @@ -658,6 +673,8 @@ const transactionsListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2024-12-21', @@ -711,6 +728,7 @@ const transactionsListItems = [ moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: true, violations: [ @@ -728,6 +746,8 @@ const transactionsListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2025-03-05', @@ -781,6 +801,7 @@ const transactionsListItems = [ moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: undefined, violations: [], @@ -793,6 +814,8 @@ const transactionsListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2025-03-05', @@ -846,6 +869,7 @@ const transactionsListItems = [ moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: undefined, violations: [], @@ -894,6 +918,8 @@ const transactionReportGroupListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2024-12-21', @@ -948,6 +974,7 @@ const transactionReportGroupListItems = [ modifiedMCCGroup: undefined, moneyRequestReportActionID: undefined, errors: undefined, + filename: undefined, isActionLoading: false, violations: [], }, @@ -996,6 +1023,8 @@ const transactionReportGroupListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2024-12-21', @@ -1056,6 +1085,7 @@ const transactionReportGroupListItems = [ moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, }, ], @@ -1781,6 +1811,8 @@ describe('SearchUIUtils', () => { canDelete: false, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: 'Employee Meals Remote (Fringe Benefit)', action: 'approve', comment: {