diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4edcd477c26..6e1dcb4aac7 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1035,6 +1035,7 @@ const CONST = { SHUTTER_SIZE: 90, MAX_REPORT_PREVIEW_RECEIPTS: 3, }, + RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120, REPORT: { ROLE: { ADMIN: 'admin', @@ -1503,6 +1504,8 @@ const CONST = { 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: 200, }, PRIORITY_MODE: { GSD: 'gsd', diff --git a/src/components/DistanceEReceipt.tsx b/src/components/DistanceEReceipt.tsx index 8de8546150a..19fdc23c241 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 ( - + + ); } diff --git a/src/components/TransactionItemRow/ReceiptPreview/index.native.tsx b/src/components/TransactionItemRow/ReceiptPreview/index.native.tsx new file mode 100644 index 00000000000..eb62a4c48e3 --- /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 00000000000..a444b4cc577 --- /dev/null +++ b/src/components/TransactionItemRow/ReceiptPreview/index.tsx @@ -0,0 +1,146 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import ReactDOM from 'react-dom'; +import type {LayoutChangeEvent} from 'react-native'; +import {View} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import DistanceEReceipt from '@components/DistanceEReceipt'; +import EReceipt from '@components/EReceipt'; +import BaseImage from '@components/Image/BaseImage'; +import type {ImageOnLoadEvent} from '@components/Image/types'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {isDistanceRequest} from '@libs/TransactionUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {Transaction} from '@src/types/onyx'; + +const eReceiptAspectRatio = variables.eReceiptBGHWidth / variables.eReceiptBGHeight; + +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 [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 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)); + 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( + (event: ImageOnLoadEvent) => { + const {width, height} = event.nativeEvent; + + updateImageAspectRatio(width, height); + }, + [updateImageAspectRatio], + ); + + const handleEReceiptLayout = (e: LayoutChangeEvent) => { + const {width} = e.nativeEvent.layout; + setEReceiptScaleFactor(width / variables.eReceiptBGHWidth); + }; + + useEffect(() => { + setShouldShow(hovered); + }, [hovered, setShouldShow]); + + if (shouldUseNarrowLayout || !debounceShouldShow || !shouldShow || (!source && !isEReceipt && !isDistanceEReceipt)) { + return null; + } + + const shouldShowImage = source && !(isEReceipt || isDistanceEReceipt); + const shouldShowDistanceEReceipt = isDistanceEReceipt && !isEReceipt; + + return ReactDOM.createPortal( + + {shouldShowImage ? ( + + + + ) : ( + + {shouldShowDistanceEReceipt ? ( + + + + ) : ( + + + + )} + + )} + , + document.body, + ); +} + +export default ReceiptPreview; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 14fe4733f01..3167d147895 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -773,12 +773,10 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata const report = data[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`]; const policy = data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const shouldShowBlankTo = !report || isOpenExpenseReport(report); - const transactionViolations = getTransactionViolations(allViolations, transactionItem); // Use Map.get() for faster lookups with default values const from = personalDetailsMap.get(transactionItem.accountID.toString()) ?? emptyPersonalDetails; const to = transactionItem.managerID && !shouldShowBlankTo ? (personalDetailsMap.get(transactionItem.managerID.toString()) ?? emptyPersonalDetails) : emptyPersonalDetails; - const {formattedFrom, formattedTo, formattedTotal, formattedMerchant, date} = getTransactionItemCommonFormattedProperties(transactionItem, from, to, policy); const transactionSection: TransactionListItemType = { @@ -799,7 +797,7 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata isAmountColumnWide: shouldShowAmountInWideColumn, isTaxAmountColumnWide: shouldShowTaxAmountInWideColumn, violations: transactionViolations, - + filename: transactionItem.filename, // Manually copying all the properties from transactionItem transactionID: transactionItem.transactionID, created: transactionItem.created, @@ -837,6 +835,8 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata 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 e2a46a5bc40..51a8b66d8bf 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5664,6 +5664,32 @@ const styles = (theme: ThemeColors) => aspectRatio: 1.7, }, + receiptPreview: { + position: 'absolute', + 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, + }, + + receiptPreviewEReceipt: { + ...flex.flexColumn, + ...flex.justifyContentCenter, + ...flex.alignItemsCenter, + }, + topBarWrapper: { zIndex: 15, }, diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index e20e038b80f..f1fdcdf074f 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -391,6 +391,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; @@ -417,6 +420,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 293b6727531..fe11b41cab3 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -238,6 +238,8 @@ const searchResults: OnyxTypes.SearchResults = { canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: { comment: '', @@ -269,6 +271,7 @@ const searchResults: OnyxTypes.SearchResults = { modifiedMCCGroup: undefined, moneyRequestReportActionID: undefined, errors: undefined, + filename: undefined, isActionLoading: false, }, [`transactions_${transactionID2}`]: { @@ -278,6 +281,8 @@ const searchResults: OnyxTypes.SearchResults = { canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: { comment: '', @@ -309,6 +314,7 @@ const searchResults: OnyxTypes.SearchResults = { moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, }, ...allViolations, @@ -319,6 +325,8 @@ const searchResults: OnyxTypes.SearchResults = { canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: { comment: '', @@ -349,6 +357,7 @@ const searchResults: OnyxTypes.SearchResults = { moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: undefined, }, @@ -359,6 +368,8 @@ const searchResults: OnyxTypes.SearchResults = { canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: { comment: '', @@ -389,6 +400,7 @@ const searchResults: OnyxTypes.SearchResults = { moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: undefined, }, @@ -449,6 +461,8 @@ const transactionsListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2024-12-21', @@ -502,6 +516,7 @@ const transactionsListItems = [ modifiedMCCGroup: undefined, moneyRequestReportActionID: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: false, violations: [], @@ -513,6 +528,8 @@ const transactionsListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2024-12-21', @@ -566,6 +583,7 @@ const transactionsListItems = [ moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: true, violations: [ @@ -582,6 +600,8 @@ const transactionsListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2025-03-05', @@ -635,6 +655,7 @@ const transactionsListItems = [ moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: undefined, violations: [], @@ -646,6 +667,8 @@ const transactionsListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2025-03-05', @@ -699,6 +722,7 @@ const transactionsListItems = [ moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, hasViolation: undefined, violations: [], @@ -746,6 +770,8 @@ const transactionReportGroupListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2024-12-21', @@ -800,6 +826,7 @@ const transactionReportGroupListItems = [ modifiedMCCGroup: undefined, moneyRequestReportActionID: undefined, errors: undefined, + filename: undefined, isActionLoading: false, violations: [], }, @@ -847,6 +874,8 @@ const transactionReportGroupListItems = [ canDelete: true, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: '', comment: {comment: ''}, created: '2024-12-21', @@ -907,6 +936,7 @@ const transactionReportGroupListItems = [ moneyRequestReportActionID: undefined, pendingAction: undefined, errors: undefined, + filename: undefined, isActionLoading: false, }, ], @@ -1448,6 +1478,8 @@ describe('SearchUIUtils', () => { canDelete: false, canHold: true, canUnhold: false, + cardID: undefined, + cardName: undefined, category: 'Employee Meals Remote (Fringe Benefit)', action: 'approve', comment: {