diff --git a/src/CONST.ts b/src/CONST.ts index 00e17af072cf..3f141905e84c 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2093,8 +2093,12 @@ const CONST = { NAME_USER_FRIENDLY: { netsuite: 'NetSuite', quickbooksOnline: 'Quickbooks Online', + quickbooksDesktop: 'Quickbooks Desktop', xero: 'Xero', intacct: 'Sage Intacct', + financialForce: 'FinancialForce', + billCom: 'Bill.com', + zenefits: 'Zenefits', }, SYNC_STAGE_NAME: { STARTING_IMPORT_QBO: 'startingImportQBO', diff --git a/src/components/ReportActionItem/ExportIntegration.tsx b/src/components/ReportActionItem/ExportIntegration.tsx new file mode 100644 index 000000000000..78e6170d3dd7 --- /dev/null +++ b/src/components/ReportActionItem/ExportIntegration.tsx @@ -0,0 +1,48 @@ +/* eslint-disable react/no-array-index-key */ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ReportActionUtils from '@libs/ReportActionsUtils'; +import type {ReportAction} from '@src/types/onyx'; + +type ExportIntegrationProps = { + action: OnyxEntry; +}; + +function ExportIntegration({action}: ExportIntegrationProps) { + const styles = useThemeStyles(); + const fragments = ReportActionUtils.getExportIntegrationActionFragments(action); + + return ( + + {fragments.map((fragment, index) => { + if (!fragment.url) { + return ( + + {fragment.text}{' '} + + ); + } + + return ( + + {fragment.text}{' '} + + ); + })} + + ); +} + +ExportIntegration.displayName = 'ExportIntegration'; + +export default ExportIntegration; diff --git a/src/languages/en.ts b/src/languages/en.ts index 4d957b789026..b431531bc854 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3612,7 +3612,13 @@ export default { changeType: ({oldType, newType}: ChangeTypeParams) => `changed type from ${oldType} to ${newType}`, delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `sent this report to ${delegateUser} since ${originalManager} is on vacation`, exportedToCSV: `exported this report to CSV`, - exportedToIntegration: ({label}: ExportedToIntegrationParams) => `exported this report to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[label] ?? label}`, + exportedToIntegration: { + automatic: ({label}: ExportedToIntegrationParams) => `exported this report to ${label}.`, + manual: ({label}: ExportedToIntegrationParams) => `marked this report as manually exported to ${label}.`, + reimburseableLink: 'View out of pocket expenses.', + nonReimbursableLink: 'View company card expenses.', + pending: ({label}: ExportedToIntegrationParams) => `started exporting this report to ${label}...`, + }, forwarded: ({amount, currency}: ForwardedParams) => `approved ${currency}${amount}`, integrationsMessage: (errorMessage: string, label: string) => `failed to export this report to ${label} ("${errorMessage}").`, managerAttachReceipt: `added a receipt`, diff --git a/src/languages/es.ts b/src/languages/es.ts index e3e8f5b49ac0..7325697abf28 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3668,7 +3668,13 @@ export default { changeType: ({oldType, newType}: ChangeTypeParams) => `cambió type de ${oldType} a ${newType}`, delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `envié este informe a ${delegateUser} ya que ${originalManager} está de vacaciones`, exportedToCSV: `exportó este informe a CSV`, - exportedToIntegration: ({label}: ExportedToIntegrationParams) => `exportó este informe a ${label}`, + exportedToIntegration: { + automatic: ({label}: ExportedToIntegrationParams) => `exportó este informe a ${label}.`, + manual: ({label}: ExportedToIntegrationParams) => `marcó este informe como exportado manualmente a ${label}.`, + reimburseableLink: 'Ver los gastos por cuenta propia.', + nonReimbursableLink: 'Ver los gastos de la tarjeta de empresa.', + pending: ({label}: ExportedToIntegrationParams) => `comenzó a exportar este informe a ${label}...`, + }, forwarded: ({amount, currency}: ForwardedParams) => `aprobado ${currency}${amount}`, integrationsMessage: (errorMessage: string, label: string) => `no se pudo exportar este informe a ${label} ("${errorMessage}").`, managerAttachReceipt: `agregó un recibo`, diff --git a/src/languages/types.ts b/src/languages/types.ts index 9b9e7120e8f4..24117f257d8f 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -1,5 +1,5 @@ import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; -import type {ConnectionName, Unit} from '@src/types/onyx/Policy'; +import type {Unit} from '@src/types/onyx/Policy'; import type {ViolationDataType} from '@src/types/onyx/TransactionViolation'; import type en from './en'; @@ -311,7 +311,7 @@ type ChangeTypeParams = {oldType: string; newType: string}; type DelegateSubmitParams = {delegateUser: string; originalManager: string}; -type ExportedToIntegrationParams = {label: ConnectionName}; +type ExportedToIntegrationParams = {label: string}; type ForwardedParams = {amount: string; currency: string}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 7d45fd7ea133..c552c5521219 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -737,6 +737,8 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(reportID); } else if (ReportActionUtils.isActionableAddPaymentCard(lastReportAction)) { lastMessageTextFromReport = ReportActionUtils.getReportActionMessageText(lastReportAction); + } else if (lastReportAction?.actionName === 'EXPORTINTEGRATION') { + lastMessageTextFromReport = ReportActionUtils.getExportIntegrationLastMessageText(lastReportAction); } else if (lastReportAction?.actionName && ReportActionUtils.isOldDotReportAction(lastReportAction)) { lastMessageTextFromReport = ReportActionUtils.getMessageOfOldDotReportAction(lastReportAction); } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 0ee43e3dcba9..b0ee360a86e0 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -8,7 +8,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxInputOrEntry} from '@src/types/onyx'; -import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; +import type {JoinWorkspaceResolution, OriginalMessageExportIntegration} from '@src/types/onyx/OriginalMessage'; import type Report from '@src/types/onyx/Report'; import type {Message, OldDotReportAction, OriginalMessage, ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; @@ -16,6 +16,7 @@ import type ReportActionName from '@src/types/onyx/ReportActionName'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import DateUtils from './DateUtils'; import * as Environment from './Environment/Environment'; +import getBase62ReportID from './getBase62ReportID'; import isReportMessageAttachment from './isReportMessageAttachment'; import * as Localize from './Localize'; import Log from './Log'; @@ -82,6 +83,27 @@ Onyx.connect({ let environmentURL: string; Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); +/* + * Url to the Xero non reimbursable expenses list + */ +const XERO_NON_REIMBURSABLE_EXPENSES_URL = 'https://go.xero.com/Bank/BankAccounts.aspx'; + +/* + * Url to the NetSuite global search, which should be suffixed with the reportID. + */ +const NETSUITE_NON_REIMBURSABLE_EXPENSES_URL_PREFIX = + 'https://system.netsuite.com/app/common/search/ubersearchresults.nl?quicksearch=T&searchtype=Uber&frame=be&Uber_NAMEtype=KEYWORDSTARTSWITH&Uber_NAME='; + +/* + * Url prefix to any Salesforce transaction or transaction list. + */ +const SALESFORCE_EXPENSES_URL_PREFIX = 'https://login.salesforce.com/'; + +/* + * Url to the QBO expenses list + */ +const QBO_EXPENSES_URL = 'https://qbo.intuit.com/app/expenses'; + function isCreatedAction(reportAction: OnyxInputOrEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; } @@ -1156,7 +1178,6 @@ function isOldDotReportAction(action: ReportAction | OldDotReportAction) { CONST.REPORT.ACTIONS.TYPE.CHANGE_TYPE, CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT, CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV, - CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, CONST.REPORT.ACTIONS.TYPE.FORWARDED, CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE, CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT, @@ -1220,8 +1241,6 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD } case CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV: return Localize.translateLocal('report.actions.type.exportedToCSV'); - case CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION: - return Localize.translateLocal('report.actions.type.exportedToIntegration', {label: originalMessage.label}); case CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE: { const {result, label} = originalMessage; const errorMessage = result?.messages?.join(', ') ?? ''; @@ -1434,6 +1453,86 @@ function isActionableAddPaymentCard(reportAction: OnyxEntry): repo return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_ADD_PAYMENT_CARD; } +function getExportIntegrationLastMessageText(reportAction: OnyxEntry): string { + const fragments = getExportIntegrationActionFragments(reportAction); + return fragments.reduce((acc, fragment) => `${acc} ${fragment.text}`, ''); +} + +function getExportIntegrationMessageHTML(reportAction: OnyxEntry): string { + const fragments = getExportIntegrationActionFragments(reportAction); + const htmlFragments = fragments.map((fragment) => (fragment.url ? `${fragment.text}` : fragment.text)); + return htmlFragments.join(' '); +} + +function getExportIntegrationActionFragments(reportAction: OnyxEntry): Array<{text: string; url: string}> { + if (reportAction?.actionName !== 'EXPORTINTEGRATION') { + throw Error(`received wrong action type. actionName: ${reportAction?.actionName}`); + } + + const isPending = reportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + const originalMessage = (getOriginalMessage(reportAction) ?? {}) as OriginalMessageExportIntegration; + const {label, markedManually} = originalMessage; + const reimbursableUrls = originalMessage.reimbursableUrls ?? []; + const nonReimbursableUrls = originalMessage.nonReimbursableUrls ?? []; + const reportID = reportAction?.reportID ?? ''; + const wasExportedAfterBase62 = (reportAction?.created ?? '') > '2022-11-14'; + const base62ReportID = getBase62ReportID(Number(reportID)); + + const result: Array<{text: string; url: string}> = []; + if (isPending) { + result.push({ + text: Localize.translateLocal('report.actions.type.exportedToIntegration.pending', {label}), + url: '', + }); + } else if (markedManually) { + result.push({ + text: Localize.translateLocal('report.actions.type.exportedToIntegration.manual', {label}), + url: '', + }); + } else { + result.push({ + text: Localize.translateLocal('report.actions.type.exportedToIntegration.automatic', {label}), + url: '', + }); + } + + if (reimbursableUrls.length === 1) { + result.push({ + text: Localize.translateLocal('report.actions.type.exportedToIntegration.reimburseableLink'), + url: reimbursableUrls[0], + }); + } + + if (nonReimbursableUrls.length) { + const text = Localize.translateLocal('report.actions.type.exportedToIntegration.nonReimbursableLink'); + let url = ''; + + if (nonReimbursableUrls.length === 1) { + url = nonReimbursableUrls[0]; + } else { + switch (label) { + case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.xero: + url = XERO_NON_REIMBURSABLE_EXPENSES_URL; + break; + case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.netsuite: + url = NETSUITE_NON_REIMBURSABLE_EXPENSES_URL_PREFIX; + url += wasExportedAfterBase62 ? base62ReportID : reportID; + break; + case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.financialForce: + // The first three characters in a Salesforce ID is the expense type + url = nonReimbursableUrls[0].substring(0, SALESFORCE_EXPENSES_URL_PREFIX.length + 3); + break; + default: + url = QBO_EXPENSES_URL; + } + } + + result.push({text, url}); + } + + return result; +} + export { extractLinksFromMessageHtml, getDismissedViolationMessageText, @@ -1522,6 +1621,9 @@ export { getIOUActionForReportID, getFilteredForOneTransactionView, isActionableAddPaymentCard, + getExportIntegrationActionFragments, + getExportIntegrationLastMessageText, + getExportIntegrationMessageHTML, }; export type {LastVisibleMessage}; diff --git a/src/libs/getBase62ReportID.ts b/src/libs/getBase62ReportID.ts new file mode 100644 index 000000000000..5a457e0aef2a --- /dev/null +++ b/src/libs/getBase62ReportID.ts @@ -0,0 +1,19 @@ +/** + * Take an integer reportID and convert it to a string representing a Base-62 report ID. + * + * This is in it's own module to prevent a dependency cycle between libs/ReportUtils.ts and libs/ReportActionUtils.ts + * + * @return string The reportID in base 62-format, always 12 characters beginning with `R`. + */ +export default function getBase62ReportID(reportID: number): string { + const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + let result = ''; + let remainder = reportID; + while (remainder > 0) { + const currentVal = remainder % 62; + result = alphabet[currentVal] + result; + remainder = Math.floor(remainder / 62); + } + + return `R${result.padStart(11, '0')}`; +} diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 0ff3a50bc0dd..27859cec4193 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -417,6 +417,8 @@ const ContextMenuActions: ContextMenuAction[] = [ const reason = originalMessage?.reason; const violationName = originalMessage?.violationName; Clipboard.setString(Localize.translateLocal(`violationDismissal.${violationName}.${reason}` as TranslationPaths)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { + setClipboardMessage(ReportActionsUtils.getExportIntegrationMessageHTML(reportAction)); } else if (content) { setClipboardMessage( content.replace(/()(.*?)(<\/mention-user>)/gi, (match, openTag: string, innerContent: string, closeTag: string): string => { diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 5ecd5af89fcc..10ec9bb72dff 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -21,6 +21,7 @@ import RenderHTML from '@components/RenderHTML'; import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import ChronosOOOListActions from '@components/ReportActionItem/ChronosOOOListActions'; +import ExportIntegration from '@components/ReportActionItem/ExportIntegration'; import MoneyRequestAction from '@components/ReportActionItem/MoneyRequestAction'; import RenameAction from '@components/ReportActionItem/RenameAction'; import ReportPreview from '@components/ReportActionItem/ReportPreview'; @@ -655,6 +656,8 @@ function ReportActionItem({ children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG) { children = ; + } else if (ReportActionsUtils.isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION)) { + children = ; } else { const hasBeenFlagged = ![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === moderationDecision) && diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index a24a3b3af502..2841b0a7d45c 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -402,6 +402,41 @@ type OriginalMessageApproved = { expenseReportID: string; }; +/** + * + */ +type OriginalMessageExportIntegration = { + /** + * Whether the export was done via an automation + */ + automaticAction: false; + + /** + * The integration that was exported to (display text) + */ + label: string; + + /** + * + */ + lastModified: string; + + /** + * Whether the report was manually marked as exported + */ + markedManually: boolean; + + /** + * An list of URLs to the report in the integration for company card expenses + */ + nonReimbursableUrls?: string[]; + + /** + * An list of URLs to the report in the integration for out of pocket expenses + */ + reimbursableUrls?: string[]; +}; + /** Model of `unapproved` report action */ type OriginalMessageUnapproved = { /** Unapproved expense amount */ @@ -454,7 +489,7 @@ type OriginalMessageMap = { /** */ [CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV]: never; /** */ - [CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION]: never; + [CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION]: OriginalMessageExportIntegration; /** */ [CONST.REPORT.ACTIONS.TYPE.FORWARDED]: never; /** */ @@ -562,4 +597,5 @@ export type { OriginalMessageChangeLog, JoinWorkspaceResolution, OriginalMessageModifiedExpense, + OriginalMessageExportIntegration, };