diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index cd01342c56a5..eabbdfbbced8 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -49,6 +49,11 @@ function ConfirmedRoute({transaction, isSmallerIcon, shouldHaveBorderRadius = tr const [mapboxAccessToken] = useOnyx(ONYXKEYS.MAPBOX_ACCESS_TOKEN); + useEffect(() => { + initMapboxToken(); + return stopMapboxToken; + }, []); + const getMarkerComponent = (icon: IconAsset): ReactNode => ( { - initMapboxToken(); - return stopMapboxToken; - }, []); - const hasCoordinates = getArrayDepth(coordinates) === 3 ? !!coordinates.flat().length : !!coordinates.length; const shouldDisplayMap = !requireRouteToDisplayMap || hasCoordinates; diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 611228f6f6d3..dbdabc16b9d0 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -1,31 +1,24 @@ -import React, {memo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import {shouldShowReceiptEmptyState} from '@libs/IOUUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import {isScanRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; -import ConfirmedRoute from './ConfirmedRoute'; -import InvoiceSenderField from './MoneyRequestConfirmationList/sections/InvoiceSenderField'; -import PerDiemFields from './MoneyRequestConfirmationList/sections/PerDiemFields'; import ConfirmationFieldList from './MoneyRequestConfirmationListFooter/ConfirmationFieldList'; -import ConfirmationReceiptThumbnail from './MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail'; -import useCompactReceiptDimensions from './MoneyRequestConfirmationListFooter/hooks/useCompactReceiptDimensions'; import useFooterDerivedFlags from './MoneyRequestConfirmationListFooter/hooks/useFooterDerivedFlags'; import useFooterTagVisibility from './MoneyRequestConfirmationListFooter/hooks/useFooterTagVisibility'; -import useReceiptThumbnailSource from './MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource'; -import ReceiptEmptyState from './ReceiptEmptyState'; +import DistanceMapSection from './MoneyRequestConfirmationListFooter/sections/DistanceMapSection'; +import InvoiceSenderSection from './MoneyRequestConfirmationListFooter/sections/InvoiceSenderSection'; +import PerDiemSection from './MoneyRequestConfirmationListFooter/sections/PerDiemSection'; +import ReceiptSection from './MoneyRequestConfirmationListFooter/sections/ReceiptSection'; type MoneyRequestConfirmationListFooterProps = { /** The action to perform */ @@ -248,7 +241,6 @@ function MoneyRequestConfirmationListFooter({ onSubmitForm, }: MoneyRequestConfirmationListFooterProps) { const styles = useThemeStyles(); - const {windowWidth} = useWindowDimensions(); const isInLandscapeMode = useIsInLandscapeMode(); const {isBetaEnabled} = usePermissions(); const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); @@ -262,8 +254,6 @@ function MoneyRequestConfirmationListFooter({ isPolicyExpenseChat, isReadOnly, isDistanceRequest, - isManualDistanceRequest, - isOdometerDistanceRequest, isPerDiemRequest, isTimeRequest, isTypeInvoice, @@ -277,96 +267,64 @@ function MoneyRequestConfirmationListFooter({ transaction, }); - const receiptSource = useReceiptThumbnailSource({transaction, receiptPath, receiptFilename}); - - const horizontalMargin = typeof styles.moneyRequestImage.marginHorizontal === 'number' ? styles.moneyRequestImage.marginHorizontal : 0; - const compact = useCompactReceiptDimensions({ - showMoreFields, - isScan: flags.isScan, - isInLandscapeMode, - windowWidth, - horizontalMargin, - }); - - const showReceiptEmptyState = shouldShowReceiptEmptyState(iouType, action, policy, isPerDiemRequest); - const perDiemCustomUnit = getPerDiemCustomUnit(policy); + // ReceiptSection owns the receipt thumbnail + compact-mode hooks; the outer wrapper only needs this boolean. + const isCompactMode = !showMoreFields && isScanRequest(transaction) && !isInLandscapeMode; return ( - + - {isTypeInvoice && ( - - )} - {flags.shouldShowMap && ( - - - - )} - {isPerDiemRequest && action !== CONST.IOU.ACTION.SUBMIT && ( - - )} + + + - {(!flags.shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest) && - (receiptSource.hasReceiptImageOrThumbnail || isLoadingReceipt ? ( - - ) : ( - showReceiptEmptyState && ( - { - if (!transactionID) { - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRoute())); - }} - style={[ - compact.isCompactMode ? undefined : styles.mv3, - compact.isCompactMode && compact.compactReceiptStyle ? compact.compactReceiptStyle : styles.moneyRequestViewImage, - ]} - /> - ) - ))} + ); } -export default memo(MoneyRequestConfirmationListFooter); +export default MoneyRequestConfirmationListFooter; diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/ClassificationFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/ClassificationFields.tsx index 634e6493d778..77511d7060ae 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/ClassificationFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/ClassificationFields.tsx @@ -11,6 +11,81 @@ import type {IOUAction, IOUType} from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; import type {FieldVisibility, TagEntry} from './fieldVisibility'; +type TagFieldRowProps = { + /** Tag entry to render (carries name, index, and required flag) */ + entry: TagEntry; + + /** Tag lists configured on the policy (used to look up the list at the entry's index) */ + policyTagLists: Array>; + + /** Previous render's per-tag-list `shouldShow` projection (drives transition styling) */ + previousTagsVisibility: boolean[]; + + /** Whether the user has confirmed (locks editable controls) */ + didConfirm: boolean; + + /** Whether the surface is read-only */ + isReadOnly: boolean; + + /** ID of the active transaction */ + transactionID: string | undefined; + + /** Action being performed (drives section navigation targets) */ + action: IOUAction; + + /** Type of IOU being confirmed */ + iouType: Exclude; + + /** ID of the report the transaction belongs to */ + reportID: string; + + /** ID of the originating report action when editing */ + reportActionID: string | undefined; + + /** Active transaction */ + transaction: OnyxEntry; + + /** Form-level error message */ + formError: string; +}; + +function TagFieldRow({ + entry: {index, isTagRequired}, + policyTagLists, + previousTagsVisibility, + didConfirm, + isReadOnly, + transactionID, + action, + iouType, + reportID, + reportActionID, + transaction, + formError, +}: TagFieldRowProps) { + const policyTagList = policyTagLists.at(index); + if (!policyTagList) { + return null; + } + return ( + + ); +} + type ClassificationFieldsProps = { /** Action being performed (drives section navigation targets) */ action: IOUAction; @@ -103,31 +178,19 @@ function ClassificationFields({ isCompactMode, fieldVisibility, }: ClassificationFieldsProps) { - const renderTagFields = (entries: TagEntry[]) => - entries.map(({name, index, isTagRequired}) => { - const policyTagList = policyTagLists.at(index); - if (!policyTagList) { - return null; - } - return ( - - ); - }); + const tagRowSharedProps = { + policyTagLists, + previousTagsVisibility, + didConfirm, + isReadOnly, + transactionID, + action, + iouType, + reportID, + reportActionID, + transaction, + formError, + }; return ( <> @@ -163,9 +226,42 @@ function ClassificationFields({ /> )} - {renderTagFields(fieldVisibility.tagsRequired)} + {fieldVisibility.tagsRequired.map((entry) => ( + + ))} - {!isCompactMode && renderTagFields(fieldVisibility.tagsOptional)} + {!isCompactMode && + fieldVisibility.tagsOptional.map((entry) => ( + + ))} {!isCompactMode && fieldVisibility.tax && ( ; + + /** Whether the active transaction is a distance request (gate for showing the map) */ + isDistanceRequest: boolean; + + /** Whether the active transaction is a manual distance request (suppresses the map) */ + isManualDistanceRequest: boolean; + + /** Whether the active transaction is an odometer-driven distance request (suppresses the map) */ + isOdometerDistanceRequest: boolean; + + /** Type of IOU being confirmed (splits never show the map) */ + iouType: Exclude; + + /** Whether the surface is read-only (read-only without errors/pending hides the map) */ + isReadOnly: boolean; +}; + +function DistanceMapSection({transaction, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, iouType, isReadOnly}: DistanceMapSectionProps) { + const styles = useThemeStyles(); + + const shouldShowMap = shouldShowDistanceMap({transaction, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, iouType, isReadOnly}); + + if (!shouldShowMap) { + return null; + } + + return ( + + + + ); +} + +export default DistanceMapSection; diff --git a/src/components/MoneyRequestConfirmationListFooter/sections/InvoiceSenderSection.tsx b/src/components/MoneyRequestConfirmationListFooter/sections/InvoiceSenderSection.tsx new file mode 100644 index 000000000000..7a806793ce87 --- /dev/null +++ b/src/components/MoneyRequestConfirmationListFooter/sections/InvoiceSenderSection.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import InvoiceSenderField from '@components/MoneyRequestConfirmationList/sections/InvoiceSenderField'; +import CONST from '@src/CONST'; +import type {IOUType} from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; + +type InvoiceSenderSectionProps = { + /** Type of IOU being confirmed (this section only renders when iouType === INVOICE) */ + iouType: Exclude; + + /** ID of the report the transaction belongs to */ + reportID: string; + + /** Selected participants (used to derive the sender workspace) */ + selectedParticipants: Participant[]; + + /** Whether the surface is read-only */ + isReadOnly: boolean; + + /** Whether the user has confirmed (locks editable controls) */ + didConfirm: boolean; + + /** Active transaction */ + transaction: OnyxEntry; +}; + +function InvoiceSenderSection({iouType, reportID, selectedParticipants, isReadOnly, didConfirm, transaction}: InvoiceSenderSectionProps) { + if (iouType !== CONST.IOU.TYPE.INVOICE) { + return null; + } + return ( + + ); +} + +export default InvoiceSenderSection; diff --git a/src/components/MoneyRequestConfirmationListFooter/sections/PerDiemSection.tsx b/src/components/MoneyRequestConfirmationListFooter/sections/PerDiemSection.tsx new file mode 100644 index 000000000000..141db1f5f56e --- /dev/null +++ b/src/components/MoneyRequestConfirmationListFooter/sections/PerDiemSection.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import PerDiemFields from '@components/MoneyRequestConfirmationList/sections/PerDiemFields'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import type {IOUAction, IOUType} from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type PerDiemSectionProps = { + /** Action being performed (per-diem fields are hidden on SUBMIT) */ + action: IOUAction; + + /** Type of IOU being confirmed */ + iouType: Exclude; + + /** Whether the active transaction is a per-diem request (gate for rendering this section) */ + isPerDiemRequest: boolean; + + /** Active transaction */ + transaction: OnyxEntry; + + /** ID of the report the transaction belongs to */ + reportID: string; + + /** ID of the active transaction */ + transactionID: string | undefined; + + /** Active policy (used to resolve the per-diem custom unit) */ + policy: OnyxEntry; + + /** Whether the surface is read-only */ + isReadOnly: boolean; + + /** Whether the user has confirmed (locks editable controls) */ + didConfirm: boolean; + + /** Whether to display per-field validation errors */ + shouldDisplayFieldError: boolean; + + /** Form-level error message */ + formError: string; +}; + +function PerDiemSection({action, iouType, isPerDiemRequest, transaction, reportID, transactionID, policy, isReadOnly, didConfirm, shouldDisplayFieldError, formError}: PerDiemSectionProps) { + if (!isPerDiemRequest || action === CONST.IOU.ACTION.SUBMIT) { + return null; + } + + const perDiemCustomUnit = getPerDiemCustomUnit(policy); + + return ( + + ); +} + +export default PerDiemSection; diff --git a/src/components/MoneyRequestConfirmationListFooter/sections/ReceiptSection.tsx b/src/components/MoneyRequestConfirmationListFooter/sections/ReceiptSection.tsx new file mode 100644 index 000000000000..1ceb128295bc --- /dev/null +++ b/src/components/MoneyRequestConfirmationListFooter/sections/ReceiptSection.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import ConfirmationReceiptThumbnail from '@components/MoneyRequestConfirmationListFooter/ConfirmationReceiptThumbnail'; +import useCompactReceiptDimensions from '@components/MoneyRequestConfirmationListFooter/hooks/useCompactReceiptDimensions'; +import useReceiptThumbnailSource from '@components/MoneyRequestConfirmationListFooter/hooks/useReceiptThumbnailSource'; +import shouldShowDistanceMap from '@components/MoneyRequestConfirmationListFooter/shouldShowDistanceMap'; +import ReceiptEmptyState from '@components/ReceiptEmptyState'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {shouldShowReceiptEmptyState} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {isScanRequest} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import type {IOUAction, IOUType} from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; + +type ReceiptSectionProps = { + /** Active transaction (drives receipt source resolution + scan-mode compact dimensions) */ + transaction: OnyxEntry; + + /** ID of the active transaction */ + transactionID: string | undefined; + + /** ID of the report the transaction belongs to */ + reportID: string; + + /** Action being performed (drives the receipt-scan navigation target) */ + action: IOUAction; + + /** Type of IOU being confirmed */ + iouType: Exclude; + + /** Active policy (used to decide whether the receipt empty state should render) */ + policy: OnyxEntry; + + /** Whether the active transaction is a per-diem request */ + isPerDiemRequest: boolean; + + /** Whether the active transaction is a distance request (suppresses receipt area unless manual/odometer) */ + isDistanceRequest: boolean; + + /** Whether the active transaction is a manual distance request */ + isManualDistanceRequest: boolean; + + /** Whether the active transaction is an odometer-driven distance request */ + isOdometerDistanceRequest: boolean; + + /** Whether the surface is read-only */ + isReadOnly: boolean; + + /** Whether the receipt can be replaced */ + isReceiptEditable: boolean; + + /** Whether the receipt should be displayed */ + shouldDisplayReceipt: boolean; + + /** Whether the receipt is currently being stitched */ + isLoadingReceipt: boolean; + + /** Path of the receipt asset (URL or local) */ + receiptPath: string | number; + + /** Filename of the receipt asset */ + receiptFilename: string; + + /** Whether optional fields are expanded (drives compact-mode dimensions) */ + showMoreFields: boolean; + + /** Callback when the receipt PDF fails to load */ + onPDFLoadError?: () => void; + + /** Callback when the receipt PDF requires a password */ + onPDFPassword?: () => void; +}; + +function ReceiptSection({ + transaction, + transactionID, + reportID, + action, + iouType, + policy, + isPerDiemRequest, + isDistanceRequest, + isManualDistanceRequest, + isOdometerDistanceRequest, + isReadOnly, + isReceiptEditable, + shouldDisplayReceipt, + isLoadingReceipt, + receiptPath, + receiptFilename, + showMoreFields, + onPDFLoadError, + onPDFPassword, +}: ReceiptSectionProps) { + const styles = useThemeStyles(); + const {windowWidth} = useWindowDimensions(); + const isInLandscapeMode = useIsInLandscapeMode(); + + const receiptSource = useReceiptThumbnailSource({transaction, receiptPath, receiptFilename}); + + const horizontalMargin = typeof styles.moneyRequestImage.marginHorizontal === 'number' ? styles.moneyRequestImage.marginHorizontal : 0; + const isScan = isScanRequest(transaction); + const compact = useCompactReceiptDimensions({ + showMoreFields, + isScan, + isInLandscapeMode, + windowWidth, + horizontalMargin, + }); + + // When a GPS distance map is visible, the receipt is hidden (unless manual/odometer). + const shouldShowMap = shouldShowDistanceMap({transaction, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, iouType, isReadOnly}); + const shouldShowReceiptArea = !shouldShowMap || isManualDistanceRequest || isOdometerDistanceRequest; + + if (!shouldShowReceiptArea) { + return null; + } + + if (receiptSource.hasReceiptImageOrThumbnail || isLoadingReceipt) { + return ( + + ); + } + + const showReceiptEmptyState = shouldShowReceiptEmptyState(iouType, action, policy, isPerDiemRequest); + if (!showReceiptEmptyState) { + return null; + } + + return ( + { + if (!transactionID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRoute())); + }} + style={[compact.isCompactMode ? undefined : styles.mv3, compact.isCompactMode && compact.compactReceiptStyle ? compact.compactReceiptStyle : styles.moneyRequestViewImage]} + /> + ); +} + +export default ReceiptSection; +export type {ReceiptSectionProps}; diff --git a/src/components/MoneyRequestConfirmationListFooter/shouldShowDistanceMap.ts b/src/components/MoneyRequestConfirmationListFooter/shouldShowDistanceMap.ts new file mode 100644 index 000000000000..b1a87f7df758 --- /dev/null +++ b/src/components/MoneyRequestConfirmationListFooter/shouldShowDistanceMap.ts @@ -0,0 +1,26 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {isFetchingWaypointsFromServer} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import type {IOUType} from '@src/CONST'; +import type {Transaction} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type ShouldShowDistanceMapParams = { + transaction: OnyxEntry; + isDistanceRequest: boolean; + isManualDistanceRequest: boolean; + isOdometerDistanceRequest: boolean; + iouType: Exclude; + isReadOnly: boolean; +}; + +function shouldShowDistanceMap({transaction, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, iouType, isReadOnly}: ShouldShowDistanceMapParams): boolean { + if (!isDistanceRequest || isManualDistanceRequest || isOdometerDistanceRequest) { + return false; + } + const hasPendingWaypoints = transaction && isFetchingWaypointsFromServer(transaction); + const hasErrors = !isEmptyObject(transaction?.errors) || !isEmptyObject(transaction?.errorFields?.route) || !isEmptyObject(transaction?.errorFields?.waypoints); + return [hasErrors, hasPendingWaypoints, iouType !== CONST.IOU.TYPE.SPLIT, !isReadOnly].some(Boolean); +} + +export default shouldShowDistanceMap;