diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0eb13e95ef83f..0c107a10a9769 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6960,6 +6960,14 @@ const CONST = { description: 'workspace.upgrade.travel.description' as const, icon: 'Luggage', }, + reports: { + id: 'reports' as const, + alias: 'reports', + name: 'Reports', + title: 'workspace.upgrade.reports.title' as const, + description: 'workspace.upgrade.reports.description' as const, + icon: 'ReportReceipt', + }, distanceRates: { id: 'distanceRates' as const, alias: 'distance-rates', diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3b1ad6aae3c1b..fdb9c62bb0d59 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -267,7 +267,8 @@ function MoneyRequestView({ // Flags for showing categories and tags // transactionCategory can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldShowCategory = (isPolicyExpenseChat && (categoryForDisplay || hasEnabledOptions(policyCategories ?? {}))) || isExpenseUnreported; + const areCategoriesEnabledAndHasCategory = categoryForDisplay || hasEnabledOptions(policyCategories ?? {}); + const shouldShowCategory = (isPolicyExpenseChat && areCategoriesEnabledAndHasCategory) || (isExpenseUnreported && (!activePolicy || areCategoriesEnabledAndHasCategory)); // transactionTag can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const shouldShowTag = shouldShowPolicySpecificFields && (transactionTag || hasEnabledTags(policyTagLists)); @@ -275,7 +276,7 @@ function MoneyRequestView({ const isCurrentTransactionReimbursableDifferentFromPolicyDefault = policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; const shouldShowReimbursable = - isPolicyExpenseChat && (!policy?.disabledFields?.reimbursable || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && !isCardTransaction && !isInvoice; + shouldShowPolicySpecificFields && (!policy?.disabledFields?.reimbursable || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && !isCardTransaction && !isInvoice; const canEditReimbursable = isEditable && canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, undefined, isChatReportArchived); const shouldShowAttendees = useMemo(() => shouldShowAttendeesTransactionUtils(iouType, policy), [iouType, policy]); @@ -827,6 +828,20 @@ function MoneyRequestView({ if (!canEditReport) { return; } + + if (!policy) { + Navigation.navigate( + ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + iouType, + action: CONST.IOU.ACTION.EDIT, + transactionID: transaction?.transactionID, + reportID: report.reportID, + upgradePath: CONST.UPGRADE_PATHS.REPORTS, + }), + ); + return; + } + Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, report.reportID, getReportRHPActiveRoute()), ); diff --git a/src/languages/de.ts b/src/languages/de.ts index e872de7a793fe..3cee48bc57668 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5559,6 +5559,12 @@ const translations = { 'Expensify Travel ist eine neue Plattform für die Buchung und Verwaltung von Geschäftsreisen, die es Mitgliedern ermöglicht, Unterkünfte, Flüge, Transportmittel und mehr zu buchen.', onlyAvailableOnPlan: 'Reisen ist im Collect-Plan verfügbar, beginnend bei', }, + reports: { + title: 'Berichte', + description: + 'Erstellen Sie organisierte Spesenabrechnungen, um Ihre Geschäftsausgaben zu verfolgen, zur Genehmigung einzureichen und Ihren Erstattungsprozess zu optimieren.', + onlyAvailableOnPlan: 'Berichte sind im Collect-Plan verfügbar, beginnend bei ', + }, multiLevelTags: { title: 'Mehrstufige Tags', description: diff --git a/src/languages/en.ts b/src/languages/en.ts index 2917a2ec5f983..e8be1eb3efbdd 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5529,6 +5529,11 @@ const translations = { description: 'Expensify Travel is a new corporate travel booking and management platform that allows members to book accommodations, flights, transportation, and more.', onlyAvailableOnPlan: 'Travel is available on the Collect plan, starting at ', }, + reports: { + title: 'Reports', + description: 'Create organized expense reports to track your business spending, submit for approvals, and streamline your reimbursement process.', + onlyAvailableOnPlan: 'Reports are available on the Collect plan, starting at ', + }, multiLevelTags: { title: 'Multi-level tags', description: diff --git a/src/languages/es.ts b/src/languages/es.ts index 331b990e82536..809994287645a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5562,6 +5562,11 @@ const translations = { 'Expensify Travel es una nueva plataforma corporativa de reserva y gestión de viajes que permite a los miembros reservar alojamientos, vuelos, transporte y mucho más.', onlyAvailableOnPlan: 'Los viajes están disponibles en el plan Recopilar, a partir de ', }, + reports: { + title: 'Informes', + description: 'Crea informes de gastos organizados para hacer seguimiento de tus gastos comerciales, enviarlos para aprobación y optimizar tu proceso de reembolso.', + onlyAvailableOnPlan: 'Los informes están disponibles en el plan Recopilar, a partir de ', + }, multiLevelTags: { title: 'Etiquetas multinivel', description: diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d0aad694d58c8..28d38ef070781 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5569,6 +5569,12 @@ const translations = { "Expensify Travel est une nouvelle plateforme de réservation et de gestion de voyages d'affaires qui permet aux membres de réserver des hébergements, des vols, des transports, et plus encore.", onlyAvailableOnPlan: 'Le voyage est disponible sur le plan Collect, à partir de', }, + reports: { + title: 'Rapports', + description: + 'Créez des rapports de dépenses organisés pour suivre vos dépenses professionnelles, les soumettre pour approbation et rationaliser votre processus de remboursement.', + onlyAvailableOnPlan: 'Les rapports sont disponibles sur le plan Collect, à partir de ', + }, multiLevelTags: { title: 'Tags multi-niveaux', description: diff --git a/src/languages/it.ts b/src/languages/it.ts index 162c93fde4f57..7c69b86f34267 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5568,6 +5568,11 @@ const translations = { 'Expensify Travel è una nuova piattaforma aziendale per la prenotazione e la gestione dei viaggi che consente ai membri di prenotare alloggi, voli, trasporti e altro.', onlyAvailableOnPlan: 'Il viaggio è disponibile nel piano Collect, a partire da', }, + reports: { + title: 'Report', + description: 'Crea report spese organizzati per tenere traccia delle tue spese aziendali, inviarli per approvazione e semplificare il processo di rimborso.', + onlyAvailableOnPlan: 'I report sono disponibili nel piano Collect, a partire da ', + }, multiLevelTags: { title: 'Tag multi-livello', description: diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2d0c7d60b982a..ed96c3c835517 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5522,6 +5522,11 @@ const translations = { description: 'Expensify Travelは、メンバーが宿泊施設、フライト、交通機関などを予約できる新しい法人向け旅行予約および管理プラットフォームです。', onlyAvailableOnPlan: '旅行は、Collectプランで利用可能です。料金は', }, + reports: { + title: 'レポート', + description: '組織化された経費レポートを作成して、ビジネス支出を追跡し、承認のために提出し、払い戻しプロセスを合理化します。', + onlyAvailableOnPlan: 'レポートは、Collectプランで利用可能です。料金は ', + }, multiLevelTags: { title: 'マルチレベルタグ', description: diff --git a/src/languages/nl.ts b/src/languages/nl.ts index bc80c68c71c25..050b7b4260245 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5559,6 +5559,11 @@ const translations = { description: 'Expensify Travel is een nieuw platform voor het boeken en beheren van zakelijke reizen waarmee leden accommodaties, vluchten, vervoer en meer kunnen boeken.', onlyAvailableOnPlan: 'Reizen is beschikbaar op het Collect-plan, beginnend bij', }, + reports: { + title: 'Rapporten', + description: 'Maak georganiseerde onkostenrapporten om uw zakelijke uitgaven bij te houden, in te dienen voor goedkeuring en uw vergoedingsproces te stroomlijnen.', + onlyAvailableOnPlan: 'Rapporten zijn beschikbaar op het Collect-plan, beginnend bij ', + }, multiLevelTags: { title: 'Meerniveautags', description: diff --git a/src/languages/pl.ts b/src/languages/pl.ts index aaea6b33ceca9..e67223fc8021f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5548,6 +5548,11 @@ const translations = { 'Expensify Travel to nowa platforma do rezerwacji i zarządzania podróżami służbowymi, która umożliwia członkom rezerwację zakwaterowania, lotów, transportu i nie tylko.', onlyAvailableOnPlan: 'Podróże są dostępne w planie Collect, zaczynając od', }, + reports: { + title: 'Raporty', + description: 'Twórz uporządkowane raporty wydatków, aby śledzić swoje wydatki biznesowe, przesyłać je do zatwierdzenia i usprawniać proces zwrotu kosztów.', + onlyAvailableOnPlan: 'Raporty są dostępne w planie Collect, zaczynając od ', + }, multiLevelTags: { title: 'Wielopoziomowe tagi', description: diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index bef63e20b2e00..c97c77a9f9676 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5558,6 +5558,11 @@ const translations = { description: 'Expensify Travel é uma nova plataforma de reserva e gestão de viagens corporativas que permite aos membros reservar acomodações, voos, transporte e mais.', onlyAvailableOnPlan: 'Viagens estão disponíveis no plano Collect, a partir de', }, + reports: { + title: 'Relatórios', + description: 'Crie relatórios de despesas organizados para acompanhar seus gastos empresariais, enviá-los para aprovação e otimizar seu processo de reembolso.', + onlyAvailableOnPlan: 'Os relatórios estão disponíveis no plano Collect, a partir de ', + }, multiLevelTags: { title: 'Tags multiníveis', description: diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 6a69a81f902b6..a7850dbbe734d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5459,6 +5459,11 @@ const translations = { description: 'Expensify Travel 是一个新的企业差旅预订和管理平台,允许会员预订住宿、航班、交通等。', onlyAvailableOnPlan: '旅行功能在 Collect 计划中提供,起价为', }, + reports: { + title: '报告', + description: '创建有序的费用报告来跟踪您的商业开支,提交审批,并简化您的报销流程。', + onlyAvailableOnPlan: '报告功能在 Collect 计划中提供,起价为 ', + }, multiLevelTags: { title: '多级标签', description: '多级标签帮助您更精确地跟踪费用。为每个项目分配多个标签,例如部门、客户或成本中心,以捕获每笔费用的完整上下文。这使得更详细的报告、审批流程和会计导出成为可能。', diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index ec3fadb7ce437..d9049404f321b 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -308,7 +308,7 @@ function getForReportAction({ buildMessageFragmentForValue(taxAmount, oldTaxAmount, translateLocal('iou.taxAmount'), false, setFragments, removalFragments, changeFragments); } - const hasModifiedTaxRate = isReportActionOriginalMessageAnObject && 'oldTaxRate' in reportActionOriginalMessage && 'taxRate' in reportActionOriginalMessage; + const hasModifiedTaxRate = isReportActionOriginalMessageAnObject && ('oldTaxRate' in reportActionOriginalMessage || 'taxRate' in reportActionOriginalMessage); if (hasModifiedTaxRate) { buildMessageFragmentForValue( reportActionOriginalMessage?.taxRate ?? '', diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index bff77bcb9538c..434b8647e9739 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -15,13 +15,6 @@ function canUseLinkPreviews(): boolean { return false; } -/** - * Temporary check for Unreported Expense Project - change to true for testing - */ -function canUseUnreportedExpense(): boolean { - return false; -} - function isBetaEnabled(beta: Beta, betas: OnyxEntry, betaConfiguration?: OnyxEntry): boolean { const hasAllBetasEnabled = canUseAllBetas(betas); const isFeatureEnabled = !!betas?.includes(beta); @@ -38,5 +31,4 @@ function isBetaEnabled(beta: Beta, betas: OnyxEntry, betaConfiguration?: export default { canUseLinkPreviews, isBetaEnabled, - canUseUnreportedExpense, }; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 51d5a5a7b5526..77d6fcad970ed 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4382,15 +4382,21 @@ function canEditFieldOfMoneyRequest( } if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE) { + // Unreported transaction from OldDot can have the reportID as an empty string + const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; // The distance rate can be modified only on the distance expense reports - return isExpenseReport(moneyRequestReport) && isDistanceRequest(transaction); + return (isUnreportedExpense || isExpenseReport(moneyRequestReport)) && isDistanceRequest(transaction); } if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.REPORT) { // Unreported transaction from OldDot can have the reportID as an empty string const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - if (!isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID) && !isUnreportedExpense) { + if (isUnreportedExpense) { + return true; + } + + if (!isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID)) { return false; } @@ -4400,7 +4406,7 @@ function canEditFieldOfMoneyRequest( } const isOwner = moneyRequestReport?.ownerAccountID === currentUserAccountID; - if (isInvoiceReport(moneyRequestReport) && !isUnreportedExpense) { + if (isInvoiceReport(moneyRequestReport)) { return ( getOutstandingReportsForUser( moneyRequestReport?.policyID, @@ -4413,18 +4419,16 @@ function canEditFieldOfMoneyRequest( // If the report is Open, then only submitters, admins can move expenses const isOpen = isOpenExpenseReport(moneyRequestReport); - if (!isUnreportedExpense && isOpen && !isSubmitter && !isAdmin) { + if (isOpen && !isSubmitter && !isAdmin) { return false; } - return isUnreportedExpense - ? Object.values(allPolicies ?? {}).flatMap((currentPolicy) => - getOutstandingReportsForUser(currentPolicy?.id, currentUserAccountID, outstandingReportsByPolicyID?.[currentPolicy?.id ?? CONST.DEFAULT_NUMBER_ID] ?? {}), - ).length > 0 - : Object.values(allPolicies ?? {}).flatMap((currentPolicy) => - getOutstandingReportsForUser(currentPolicy?.id, moneyRequestReport?.ownerAccountID, outstandingReportsByPolicyID?.[currentPolicy?.id ?? CONST.DEFAULT_NUMBER_ID] ?? {}), - ).length > 1 || - (isOwner && isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID)); + return ( + Object.values(allPolicies ?? {}).flatMap((currentPolicy) => + getOutstandingReportsForUser(currentPolicy?.id, moneyRequestReport?.ownerAccountID, outstandingReportsByPolicyID?.[currentPolicy?.id ?? CONST.DEFAULT_NUMBER_ID] ?? {}), + ).length > 1 || + (isOwner && isReportOutstanding(moneyRequestReport, moneyRequestReport.policyID)) + ); } const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index 6339848ed9b87..2654457d949b6 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -43,7 +43,6 @@ import { isPending, isPerDiemRequest, isScanning, - isUnreportedAndHasInvalidDistanceRateTransaction, } from './TransactionUtils'; const emptyPersonalDetails: OnyxTypes.PersonalDetails = { @@ -236,10 +235,6 @@ function getTransactionPreviewTextAndTranslationPaths({ if (isDistanceRequest(transaction)) { previewHeaderText = [{translationPath: 'common.distance'}]; - - if (RBRMessage === undefined && isUnreportedAndHasInvalidDistanceRateTransaction(transaction)) { - RBRMessage = {translationPath: 'violations.customUnitOutOfPolicy'}; - } } else if (isPerDiemRequest(transaction)) { previewHeaderText = [{translationPath: 'common.perDiem'}]; } else if (isTransactionScanning) { @@ -345,11 +340,8 @@ function createTransactionPreviewConditionals({ const shouldShowCategory = !!categoryForDisplay && isReportAPolicyExpenseChat; const hasAnyViolations = - isUnreportedAndHasInvalidDistanceRateTransaction(transaction) || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - hasViolationsOfTypeNotice || - hasWarningTypeViolation(transaction, violations, true) || - hasViolation(transaction, violations, true); + hasViolationsOfTypeNotice || hasWarningTypeViolation(transaction, violations, true) || hasViolation(transaction, violations, true); const hasErrorOrOnHold = hasFieldErrors || (!isFullySettled && !isFullyApproved && isTransactionOnHold); const hasReportViolationsOrActionErrors = (isReportOwner(iouReport) && hasReportViolations(iouReport?.reportID)) || hasActionWithErrorsForTransaction(iouReport?.reportID, transaction); const shouldShowRBR = hasAnyViolations || hasErrorOrOnHold || hasReportViolationsOrActionErrors || hasReceiptError(transaction); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index f5e1454120864..603399e0ef2d8 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -18,7 +18,6 @@ import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import Permissions from '@libs/Permissions'; import {getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils'; import { getCommaSeparatedTagNameWithSanitizedColons, @@ -1952,12 +1951,7 @@ function createUnreportedExpenseSections(transactions: Array, paymentPolicyID?: string, full = true) { if (chatReport.policyID && shouldRestrictUserBillableActions(chatReport.policyID)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(chatReport.policyID)); diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index daa1fedd0f3a7..9ebab3748dc29 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -343,6 +343,7 @@ function getRoute(transactionID: string, waypoints: WaypointCollection, routeTyp API.read(command, parameters, getOnyxDataForRouteRequest(transactionID, routeType)); } + /** * Updates all waypoints stored in the transaction specified by the provided transactionID. * diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 1c3618dbaabdd..db8408f8db5b6 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -390,7 +390,8 @@ function SearchPage({route}: SearchPageProps) { }); } - const canAllTransactionsBeMoved = selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport); + const canAllTransactionsBeMoved = + selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport) && !!activePolicy && activePolicy?.type !== CONST.POLICY.TYPE.PERSONAL; if (canAllTransactionsBeMoved) { options.push({ @@ -444,6 +445,7 @@ function SearchPage({route}: SearchPageProps) { return options; }, [ + activePolicy, selectedTransactionsKeys, status, hash, diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 41d65270472c1..a5eef3067dfc3 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -3,7 +3,9 @@ import {InteractionManager} from 'react-native'; import {useSession} from '@components/OnyxListItemProvider'; import {useSearchContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionListWithSections/types'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; +import {createNewReport} from '@libs/actions/Report'; import {changeTransactionsReport} from '@libs/actions/Transaction'; import Navigation from '@libs/Navigation/Navigation'; import Permissions from '@libs/Permissions'; @@ -25,6 +27,7 @@ function SearchTransactionsChangeReport() { const isASAPSubmitBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, allBetas); const session = useSession(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const firstTransactionKey = selectedTransactionsKeys.at(0); const firstTransactionReportID = firstTransactionKey ? selectedTransactions[firstTransactionKey]?.reportID : undefined; @@ -33,6 +36,25 @@ function SearchTransactionsChangeReport() { ? firstTransactionReportID : undefined; + // Get the policy ID from the first transaction + const activePolicyID = firstTransactionKey ? selectedTransactions[firstTransactionKey]?.policyID : undefined; + + const createReport = () => { + const createdReportID = createNewReport(currentUserPersonalDetails, activePolicyID); + const reportNextStep = allReportNextSteps?.[`${ONYXKEYS.COLLECTION.NEXT_STEP}${createdReportID}`]; + changeTransactionsReport( + selectedTransactionsKeys, + createdReportID, + isASAPSubmitBetaEnabled, + session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + session?.email ?? '', + activePolicyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`] : undefined, + reportNextStep, + ); + clearSelectedTransactions(); + Navigation.goBack(); + }; + const selectReport = (item: TransactionGroupListItem) => { if (selectedTransactionsKeys.length === 0) { return; @@ -71,6 +93,7 @@ function SearchTransactionsChangeReport() { selectedReportID={selectedReportID} selectReport={selectReport} removeFromReport={removeFromReport} + createReport={createReport} isEditing /> ); diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 74f235774d7b1..2312bfd7535f1 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -17,6 +17,7 @@ import { isChatThread, isClosedExpenseReportWithNoExpenses, isCurrentUserTheOnlyParticipant, + isSelfDM, } from '@libs/ReportUtils'; import { deleteReportActionDraft, @@ -97,6 +98,7 @@ function ReportActionItem({ const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getIOUReportIDFromReportActionPreview(action)}`]; const movedFromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.FROM)}`]; const movedToReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.TO)}`]; + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); // The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here. @@ -148,7 +150,7 @@ function ReportActionItem({ )} modifiedExpenseMessage={getForReportAction({ reportAction: action, - policyID: report?.policyID, + policyID: isSelfDM(parentReport) ? activePolicyID : report?.policyID, movedFromReport, movedToReport, })} diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index 7d1361b5aeef3..aa9bc39766583 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -38,6 +38,7 @@ type Props = { isEditing?: boolean; isUnreported?: boolean; shouldShowNotFoundPage?: boolean; + createReport?: () => void; }; const policyIdSelector = (policy: OnyxEntry) => policy?.id; @@ -54,6 +55,7 @@ function IOURequestEditReportCommon({ isEditing = false, isUnreported, shouldShowNotFoundPage: shouldShowNotFoundPageFromProps, + createReport, }: Props) { const {translate, localeCompare} = useLocalize(); const {options} = useOptionsList(); @@ -62,6 +64,11 @@ function IOURequestEditReportCommon({ const [selectedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`, {canBeMissing: true}); const reportOwnerAccountID = useMemo(() => selectedReport?.ownerAccountID ?? currentUserPersonalDetails.accountID, [selectedReport, currentUserPersonalDetails.accountID]); const reportPolicy = usePolicy(selectedReport?.policyID); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { + canBeMissing: true, + selector: (policy) => (policy?.type !== CONST.POLICY.TYPE.PERSONAL ? policy : undefined), + }); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, {canBeMissing: true}); const [allPoliciesID] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesSelector, canBeMissing: false}); @@ -145,8 +152,27 @@ function IOURequestEditReportCommon({ const headerMessage = useMemo(() => (searchValue && !reportOptions.length ? translate('common.noResultsFound') : ''), [searchValue, reportOptions, translate]); + const createReportOption = useMemo(() => { + if (!createReport) { + return undefined; + } + + return ( + + ); + }, [createReport, translate, activePolicy?.name]); + // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useMemo(() => { + if (createReport) { + return false; + } + if (expenseReports.length === 0 || shouldShowNotFoundPageFromProps) { return true; } @@ -160,7 +186,7 @@ function IOURequestEditReportCommon({ const isSubmitter = isReportOwner(selectedReport); // If the report is Open, then only submitters, admins can move expenses return isOpen && !isAdmin && !isSubmitter; - }, [selectedReport, reportPolicy, expenseReports.length, shouldShowNotFoundPageFromProps]); + }, [createReport, selectedReport, reportPolicy, expenseReports.length, shouldShowNotFoundPageFromProps]); return ( - ) : undefined + <> + {shouldShowRemoveFromReport && ( + + )} + {createReportOption} + } + listEmptyContent={createReportOption} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index f2b8ad744581f..20aa18e47ffa3 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -1,5 +1,6 @@ import {useFocusEffect} from '@react-navigation/native'; import reportsSelector from '@selectors/Attributes'; +import {activePolicySelector} from '@selectors/Policy'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; @@ -20,7 +21,7 @@ import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {getPolicyExpenseChat, getTransactionDetails, isPolicyExpenseChat} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getRequestType, getTaxValue} from '@libs/TransactionUtils'; +import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getRequestType, getTaxValue, isExpenseUnreported as isExpenseUnreportedTransactionUtils} from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; import { getMoneyRequestParticipantsFromReport, @@ -78,10 +79,18 @@ function IOURequestStepAmount({ const focusTimeoutRef = useRef(null); const isSaveButtonPressed = useRef(false); const iouRequestType = getRequestType(transaction, isBetaEnabled(CONST.BETAS.MANUAL_DISTANCE)); - const policyID = report?.policyID; + const isExpenseUnreported = isExpenseUnreportedTransactionUtils(transaction); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { + canBeMissing: true, + selector: activePolicySelector, + }); + const policyID = isExpenseUnreported ? activePolicyID : report?.policyID; const isReportArchived = useReportIsArchived(report?.reportID); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); + const [reportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {canBeMissing: true}); + const policy = isExpenseUnreported ? activePolicy : reportPolicy; + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: true}); diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 60962df3058f6..23594d31a0b12 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -213,17 +213,25 @@ function IOURequestStepParticipants({ setMoneyRequestParticipants(transaction.transactionID, val); }); + const isPolicyExpenseChat = !!firstParticipant?.isPolicyExpenseChat; + const policy = isPolicyExpenseChat && firstParticipant?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${firstParticipant.policyID}`] : undefined; + if (!isMovingTransactionFromTrackExpense) { // If not moving the transaction from track expense, select the default rate automatically. // Otherwise, keep the original p2p rate and let the user manually change it to the one they want from the workspace. - const isPolicyExpenseChat = !!firstParticipant?.isPolicyExpenseChat; - const policy = isPolicyExpenseChat && firstParticipant?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${firstParticipant.policyID}`] : undefined; const rateID = DistanceRequestUtils.getCustomUnitRateID({reportID: firstParticipantReportID, isPolicyExpenseChat, policy, lastSelectedDistanceRates}); transactions.forEach((transaction) => { setCustomUnitRateID(transaction.transactionID, rateID); }); } + if (isMovingTransactionFromTrackExpense && isPolicyExpenseChat && policy?.id !== activePolicy?.id) { + transactions.forEach((transaction) => { + setMoneyRequestTag(transaction.transactionID, ''); + setMoneyRequestCategory(transaction.transactionID, ''); + }); + } + // When multiple valid participants are selected, the reportID is generated at the end of the confirmation step. // So we are resetting selectedReportID ref to the reportID coming from params. // For invoices, a valid participant must have a login. @@ -240,7 +248,7 @@ function IOURequestStepParticipants({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing selectedReportID.current = firstParticipantReportID || generateReportID(); }, - [iouType, transactions, isMovingTransactionFromTrackExpense, reportID, trackExpense, allPolicies, lastSelectedDistanceRates], + [iouType, transactions, activePolicy, allPolicies, isMovingTransactionFromTrackExpense, reportID, trackExpense, lastSelectedDistanceRates], ); const goToNextStep = useCallback(() => { @@ -255,8 +263,10 @@ function IOURequestStepParticipants({ const newReportID = selectedReportID.current; transactions.forEach((transaction) => { - setMoneyRequestTag(transaction.transactionID, ''); - setMoneyRequestCategory(transaction.transactionID, ''); + if (!isMovingTransactionFromTrackExpense) { + setMoneyRequestTag(transaction.transactionID, ''); + setMoneyRequestCategory(transaction.transactionID, ''); + } if (participants?.at(0)?.reportID !== newReportID) { setTransactionReport(transaction.transactionID, {reportID: newReportID}, true); } @@ -307,7 +317,7 @@ function IOURequestStepParticipants({ Navigation.navigate(route); } }); - }, [action, participants, iouType, initialTransaction, transactions, initialTransactionID, reportID, waitForKeyboardDismiss, backTo]); + }, [action, participants, iouType, initialTransaction, transactions, initialTransactionID, reportID, waitForKeyboardDismiss, isMovingTransactionFromTrackExpense, backTo]); const navigateBack = useCallback(() => { if (backTo) { diff --git a/src/pages/iou/request/step/IOURequestStepReport.tsx b/src/pages/iou/request/step/IOURequestStepReport.tsx index 5aeb2cb1d8bba..82b49de89e7e7 100644 --- a/src/pages/iou/request/step/IOURequestStepReport.tsx +++ b/src/pages/iou/request/step/IOURequestStepReport.tsx @@ -3,8 +3,10 @@ import {InteractionManager} from 'react-native'; import {useSession} from '@components/OnyxListItemProvider'; import {useSearchContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionListWithSections/types'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; +import {createNewReport} from '@libs/actions/Report'; import {changeTransactionsReport, setTransactionReport} from '@libs/actions/Transaction'; import Navigation from '@libs/Navigation/Navigation'; import Permissions from '@libs/Permissions'; @@ -38,6 +40,7 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { const selectedReportID = shouldUseTransactionReport ? transactionReport?.reportID : outstandingReportID; const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const {removeTransaction} = useSearchContext(); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const reportOrDraftReport = getReportOrDraftReport(reportIDFromRoute); const isEditing = action === CONST.IOU.ACTION.EDIT; const isCreateReport = action === CONST.IOU.ACTION.CREATE; @@ -45,6 +48,7 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { const [allBetas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const isASAPSubmitBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, allBetas); const session = useSession(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const handleGoBack = () => { if (isEditing) { @@ -158,6 +162,11 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, reportOrDraftReport, transaction); + const createReport = () => { + const createdReportID = createNewReport(currentUserPersonalDetails, activePolicyID); + handleRegularReportSelection({value: createdReportID}); + }; + return ( ); } diff --git a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.tsx b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.tsx index b0b3e09819106..8f8d507f0683e 100644 --- a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.tsx +++ b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.tsx @@ -8,7 +8,14 @@ import {setDraftSplitTransaction, setMoneyRequestCurrency, setMoneyRequestPartic import {convertToBackendAmount, isValidCurrencyCode} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getTransactionDetails} from '@libs/ReportUtils'; -import {calculateTaxAmount, getAmount, getDefaultTaxCode, getTaxValue, getTaxAmount as getTransactionTaxAmount} from '@libs/TransactionUtils'; +import { + calculateTaxAmount, + getAmount, + getDefaultTaxCode, + getTaxValue, + getTaxAmount as getTransactionTaxAmount, + isExpenseUnreported as isExpenseUnreportedTransactionUtils, +} from '@libs/TransactionUtils'; import type {CurrentMoney} from '@pages/iou/MoneyRequestAmountForm'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; import CONST from '@src/CONST'; @@ -48,9 +55,18 @@ function IOURequestStepTaxAmountPage({ transaction, report, }: IOURequestStepTaxAmountPageProps) { + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { + canBeMissing: true, + selector: (policy) => (policy?.type !== CONST.POLICY.TYPE.PERSONAL ? policy : undefined), + }); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {canBeMissing: true}); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`, {canBeMissing: true}); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`, {canBeMissing: true}); + const isExpenseUnreported = isExpenseUnreportedTransactionUtils(transaction); + const taxPolicy = isExpenseUnreported ? activePolicy : policy; + const taxPolicyID = isExpenseUnreported ? activePolicyID : report?.policyID; + + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${taxPolicyID}`, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${taxPolicyID}`, {canBeMissing: true}); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: true}); const {translate} = useLocalize(); const textInput = useRef(null); @@ -109,7 +125,7 @@ function IOURequestStepTaxAmountPage({ navigateBack(); return; } - updateMoneyRequestTaxAmount(transactionID, report?.reportID, taxAmountInSmallestCurrencyUnits, policy, policyTags, policyCategories); + updateMoneyRequestTaxAmount(transactionID, report?.reportID, taxAmountInSmallestCurrencyUnits, taxPolicy, policyTags, policyCategories); navigateBack(); return; } @@ -151,7 +167,7 @@ function IOURequestStepTaxAmountPage({ isEditing={!!(backTo || isEditing)} currency={currency} amount={Math.abs(transactionDetails?.taxAmount ?? 0)} - taxAmount={getTaxAmount(currentTransaction, policy, currency, !!(backTo || isEditing))} + taxAmount={getTaxAmount(currentTransaction, taxPolicy, currency, !!(backTo || isEditing))} ref={(e) => { textInput.current = e; }} diff --git a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx index f763b7ceadab2..21f7c702db2f9 100644 --- a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx @@ -74,6 +74,9 @@ function IOURequestStepUpgrade({ } Navigation.goBack(); + // If we're submitting the expense to the workspace, we don't need the backTo param + const backTo = action === CONST.IOU.ACTION.CATEGORIZE ? '' : ROUTES.REPORT_WITH_ID.getRoute(reportID); + switch (upgradePath) { case CONST.UPGRADE_PATHS.DISTANCE_RATES: { if (!policyID || !reportID) { @@ -87,7 +90,10 @@ function IOURequestStepUpgrade({ break; } case CONST.UPGRADE_PATHS.CATEGORIES: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, reportID, ROUTES.REPORT_WITH_ID.getRoute(reportID))); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, reportID, backTo)); + break; + case CONST.UPGRADE_PATHS.REPORTS: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); break; default: } @@ -168,6 +174,7 @@ function IOURequestStepUpgrade({ buttonDisabled={isOffline} loading={false} isCategorizing={isCategorizing} + isReporting={isReporting} isDistanceRateUpgrade={isDistanceRateUpgrade} /> )} diff --git a/src/pages/workspace/upgrade/UpgradeIntro.tsx b/src/pages/workspace/upgrade/UpgradeIntro.tsx index 9a90ab3c26332..6cdb53763cf8a 100644 --- a/src/pages/workspace/upgrade/UpgradeIntro.tsx +++ b/src/pages/workspace/upgrade/UpgradeIntro.tsx @@ -30,12 +30,14 @@ type Props = { onUpgrade: () => void; /** Whether is categorizing the expense */ isCategorizing?: boolean; + /** Whether is adding an unreported expense to a report */ + isReporting?: boolean; isDistanceRateUpgrade?: boolean; policyID?: string; backTo?: Route; }; -function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizing, isDistanceRateUpgrade, policyID, backTo}: Props) { +function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizing, isDistanceRateUpgrade, isReporting, policyID, backTo}: Props) { const styles = useThemeStyles(); const {isExtraSmallScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); @@ -68,7 +70,7 @@ function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizi * The "isCategorizing" flag is set to true when the user accesses the "Categorize" option in the Self-DM whisper. * In such scenarios, a separate Categories upgrade UI is displayed. */ - if (!feature || (!isCategorizing && !isDistanceRateUpgrade && !policyID)) { + if (!feature || (!isCategorizing && !isDistanceRateUpgrade && !isReporting && !policyID)) { return (