diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md index 9d17160d3a36..4075aaf18016 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md @@ -14,6 +14,8 @@ It's crucial to understand the requirements based on your specific QuickBooks su - An error will occur if you try to export to QuickBooks with a feature enabled that isn't part of your subscription. - Please be aware that Expensify does not support the Self-Employed subscription in QuickBooks Online. +![QuickBooks Online - Subscription types]({{site.url}}/assets/images/QBO1.png){:width="100%"} + # How to connect to QuickBooks Online ## Step 1: Setup employees in QuickBooks Online @@ -79,14 +81,20 @@ This is a single itemized vendor bill for each Expensify report. If the accounti The submitter will be listed as the vendor in the vendor bill. +![Vendor Bill]({{site.url}}/assets/images/QBO2-Bill.png){:width="100%"} + ## Check This is a single itemized check for each Expensify report. You can mark a check to be printed later in QuickBooks Online. +![Check to print]({{site.url}}/assets/images/QBO3-Checktoprint.png){:width="100%"} + ## Journal entry This is a single itemized journal entry for each Expensify report. +![Journal Entry]({{site.url}}/assets/images/QBO4-JournalEntry.png){:width="100%"} + # Non-reimbursable expenses Non-reimbursable expenses export to QuickBooks Online as: @@ -102,7 +110,9 @@ Using Credit/Debit Card Transactions: - Each expense will be exported as a bank transaction with its transaction date. - If you split an expense in Expensify, we'll consolidate it into a single credit card transaction in QuickBooks with multiple line items posted to the corresponding General Ledger accounts. -Pro-Tip: To ensure the payee field in QuickBooks Online reflects the merchant name for Credit Card expenses, ensure there's a matching Vendor in QuickBooks Online. Expensify checks for an exact match during export. If none are found, the payee will be mapped to a vendor we create and labeled as Credit Card Misc. or Debit Card Misc. +Pro-Tip: To ensure the payee field in QuickBooks Online reflects the merchant name for Credit Card expenses, ensure there's a matching Vendor in QuickBooks Online. Expensify checks for an exact match during export. If none are found, the payee will be mapped to a vendor we create and labeled as Credit Card Misc. or Debit Card Misc. + +![Expense]({{site.url}}/assets/images/QBO5-Expense.png){:width="100%"} If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in QuickBooks. @@ -224,6 +234,8 @@ Step 3: Importing Your Credit Card Transactions into QuickBooks Online - After completing Steps 1 and 2, you can import your credit card transactions into QuickBooks Online. These imported banking transactions will align with the ones brought in from Expensify. QuickBooks Online will guide you through the process of matching these transactions, similar to the example below: +![Transactions]({{site.url}}/assets/images/QBO7-Transactions.png){:width="100%"} + ## Tax in QuickBooks Online If your country applies taxes on sales (like GST, HST, or VAT), you can utilize Expensify's Tax Tracking along with your QuickBooks Online tax rates. Please note: Tax Tracking is not available for Workspaces linked to the US version of QuickBooks Online. If you need assistance applying taxes after reports are exported, contact QuickBooks. @@ -247,6 +259,8 @@ When working with QuickBooks Online Multi-Currency, there are some things to rem In QuickBooks Online, the currency conversion rates are not applied when exporting. All transactions will be exported with a 1:1 conversion rate, so for example, if a vendor's currency is CAD (Canadian Dollar) and the home currency is USD (US Dollar), the export will show these currencies without applying conversion rates. +![Check]({{site.url}}/assets/images/QBO6-Check.png){:width="100%"} + To correct this, you must manually update the conversion rate after the report has been exported to QuickBooks Online. Specifically for Vendor Bills: diff --git a/docs/assets/images/QBO1.png b/docs/assets/images/QBO1.png new file mode 100644 index 000000000000..911c02db70d3 Binary files /dev/null and b/docs/assets/images/QBO1.png differ diff --git a/docs/assets/images/QBO2-Bill.png b/docs/assets/images/QBO2-Bill.png new file mode 100644 index 000000000000..1aacd5980df9 Binary files /dev/null and b/docs/assets/images/QBO2-Bill.png differ diff --git a/docs/assets/images/QBO3-Checktoprint.png b/docs/assets/images/QBO3-Checktoprint.png new file mode 100644 index 000000000000..b2ab0d6f01ce Binary files /dev/null and b/docs/assets/images/QBO3-Checktoprint.png differ diff --git a/docs/assets/images/QBO4-JournalEntry.png b/docs/assets/images/QBO4-JournalEntry.png new file mode 100644 index 000000000000..289ccef5e3f9 Binary files /dev/null and b/docs/assets/images/QBO4-JournalEntry.png differ diff --git a/docs/assets/images/QBO5-Expense.png b/docs/assets/images/QBO5-Expense.png new file mode 100644 index 000000000000..32abf4d31b8e Binary files /dev/null and b/docs/assets/images/QBO5-Expense.png differ diff --git a/docs/assets/images/QBO6-Check.png b/docs/assets/images/QBO6-Check.png new file mode 100644 index 000000000000..54b5a0ce26a9 Binary files /dev/null and b/docs/assets/images/QBO6-Check.png differ diff --git a/docs/assets/images/QBO7-Transactions.png b/docs/assets/images/QBO7-Transactions.png new file mode 100644 index 000000000000..40fa7036ed47 Binary files /dev/null and b/docs/assets/images/QBO7-Transactions.png differ diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js index 1849e309f7f0..698c68cc78e9 100644 --- a/src/components/Composer/index.android.js +++ b/src/components/Composer/index.android.js @@ -4,7 +4,7 @@ import {StyleSheet} from 'react-native'; import _ from 'underscore'; import RNTextInput from '@components/RNTextInput'; import * as ComposerUtils from '@libs/ComposerUtils'; -import {useTheme} from '@styles/themes/useTheme'; +import useTheme from '@styles/themes/useTheme'; import useThemeStyles from '@styles/useThemeStyles'; const propTypes = { diff --git a/src/components/TextInput/BaseTextInput/index.native.js b/src/components/TextInput/BaseTextInput/index.native.js index 60863cfb5771..c30f932fb3a6 100644 --- a/src/components/TextInput/BaseTextInput/index.native.js +++ b/src/components/TextInput/BaseTextInput/index.native.js @@ -28,6 +28,7 @@ function BaseTextInput(props) { const styles = useThemeStyles(); const initialValue = props.value || props.defaultValue || ''; const initialActiveLabel = props.forceActiveLabel || initialValue.length > 0 || Boolean(props.prefixCharacter); + const isMultiline = props.multiline || props.autoGrowHeight; const [isFocused, setIsFocused] = useState(false); const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry); @@ -172,10 +173,12 @@ function BaseTextInput(props) { /** * Set Value & activateLabel * - * @param {String} value + * @param {String} val * @memberof BaseTextInput */ - const setValue = (value) => { + const setValue = (val) => { + const value = isMultiline ? val : val.replace(/\n/g, ' '); + if (props.onInputChange) { props.onInputChange(value); } @@ -184,7 +187,7 @@ function BaseTextInput(props) { if (value && value.length > 0) { hasValueRef.current = true; - // When the componment is uncontrolled, we need to manually activate the label: + // When the component is uncontrolled, we need to manually activate the label if (props.value === undefined) { activateLabel(); } @@ -227,7 +230,6 @@ function BaseTextInput(props) { (props.hasError || props.errorText) && styles.borderColorDanger, props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight}, ]); - const isMultiline = props.multiline || props.autoGrowHeight; return ( <> diff --git a/src/components/UnorderedList.js b/src/components/UnorderedList.js deleted file mode 100644 index c3300c11aae0..000000000000 --- a/src/components/UnorderedList.js +++ /dev/null @@ -1,37 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import useThemeStyles from '@styles/useThemeStyles'; -import Text from './Text'; - -const propTypes = { - /** An array of strings to display as an unordered list */ - items: PropTypes.arrayOf(PropTypes.string), -}; -const defaultProps = { - items: [], -}; - -function UnorderedList(props) { - const styles = useThemeStyles(); - return ( - <> - {_.map(props.items, (itemText) => ( - - {'\u2022'} - {itemText} - - ))} - - ); -} - -UnorderedList.displayName = 'UnorderedList'; -UnorderedList.propTypes = propTypes; -UnorderedList.defaultProps = defaultProps; - -export default UnorderedList; diff --git a/src/components/UnorderedList.tsx b/src/components/UnorderedList.tsx new file mode 100644 index 000000000000..a51cefce9ce6 --- /dev/null +++ b/src/components/UnorderedList.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@styles/useThemeStyles'; +import Text from './Text'; + +type UnorderedListProps = { + /** An array of strings to display as an unordered list */ + items?: string[]; +}; + +function UnorderedList({items = []}: UnorderedListProps) { + const styles = useThemeStyles(); + + return items.map((itemText) => ( + + {'\u2022'} + {itemText} + + )); +} + +UnorderedList.displayName = 'UnorderedList'; +export default UnorderedList; diff --git a/src/components/transactionPropTypes.js b/src/components/transactionPropTypes.js index 049fe60630e5..a26cf9a76cf5 100644 --- a/src/components/transactionPropTypes.js +++ b/src/components/transactionPropTypes.js @@ -31,25 +31,28 @@ export default PropTypes.shape({ modifiedMerchant: PropTypes.string, /** The comment object on the transaction */ - comment: PropTypes.shape({ - /** The text of the comment */ - comment: PropTypes.string, + comment: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + /** The text of the comment */ + comment: PropTypes.string, - /** The waypoints defining the distance request */ - waypoints: PropTypes.shape({ - /** The latitude of the waypoint */ - lat: PropTypes.number, + /** The waypoints defining the distance request */ + waypoints: PropTypes.shape({ + /** The latitude of the waypoint */ + lat: PropTypes.number, - /** The longitude of the waypoint */ - lng: PropTypes.number, + /** The longitude of the waypoint */ + lng: PropTypes.number, - /** The address of the waypoint */ - address: PropTypes.string, + /** The address of the waypoint */ + address: PropTypes.string, - /** The name of the waypoint */ - name: PropTypes.string, + /** The name of the waypoint */ + name: PropTypes.string, + }), }), - }), + ]), /** The type of transaction */ type: PropTypes.oneOf(_.values(CONST.TRANSACTION.TYPE)), diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e2b883a735bf..50a39f837fae 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1,6 +1,7 @@ import {format} from 'date-fns'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; +import {isEmpty} from 'lodash'; import lodashEscape from 'lodash/escape'; import lodashFindLastIndex from 'lodash/findLastIndex'; import lodashIntersection from 'lodash/intersection'; @@ -14,7 +15,7 @@ import CONST from '@src/CONST'; import {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {Beta, Login, PersonalDetails, Policy, PolicyTags, Report, ReportAction, Transaction} from '@src/types/onyx'; +import {Beta, Login, PersonalDetails, Policy, PolicyTags, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; import {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import {ChangeLog, IOUMessage, OriginalMessageActionName} from '@src/types/onyx/OriginalMessage'; import {Message, ReportActions} from '@src/types/onyx/ReportAction'; @@ -4227,6 +4228,21 @@ function shouldDisableWelcomeMessage(report: OnyxEntry, policy: OnyxEntr return isMoneyRequestReport(report) || isArchivedRoom(report) || !isChatRoom(report) || isChatThread(report) || !PolicyUtils.isPolicyAdmin(policy); } +/** + * Navigates to the appropriate screen based on the presence of a private note for the current user. + */ +function navigateToPrivateNotes(report: Report, session: Session) { + if (isEmpty(report) || isEmpty(session)) { + return; + } + const currentUserPrivateNote = report.privateNotes?.[String(session.accountID)]?.note ?? ''; + if (isEmpty(currentUserPrivateNote)) { + Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, String(session.accountID))); + return; + } + Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID)); +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -4391,6 +4407,7 @@ export { getChannelLogMemberMessage, getRoom, shouldDisableWelcomeMessage, + navigateToPrivateNotes, canEditWriteCapability, }; diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.js b/src/pages/PrivateNotes/PrivateNotesEditPage.js index c4946d3c07b4..2c3064d199ef 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.js +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.js @@ -18,6 +18,7 @@ import withLocalize from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; +import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/UpdateMultilineInputRange'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import personalDetailsPropType from '@pages/personalDetailsPropType'; @@ -94,9 +95,9 @@ function PrivateNotesEditPage({route, personalDetailsList, report}) { const savePrivateNote = () => { const originalNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], ''); - + let editedNote = ''; if (privateNote.trim() !== originalNote.trim()) { - const editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim()); + editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim()); Report.updatePrivateNotes(report.reportID, route.params.accountID, editedNote); } @@ -104,9 +105,11 @@ function PrivateNotesEditPage({route, personalDetailsList, report}) { debouncedSavePrivateNote(''); Keyboard.dismiss(); - - // Take user back to the PrivateNotesView page - Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID)); + if (!_.some({...report.privateNotes, [route.params.accountID]: {note: editedNote}}, (item) => item.note)) { + ReportUtils.navigateToDetailsPage(report); + } else { + Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID)); + } }; return ( @@ -117,7 +120,6 @@ function PrivateNotesEditPage({route, personalDetailsList, report}) { > Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID))} shouldShowBackButton onCloseButtonPress={() => Navigation.dismissModal()} diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.js b/src/pages/PrivateNotes/PrivateNotesListPage.js index 7bcf9c22690b..e9a4f11a7202 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.js +++ b/src/pages/PrivateNotes/PrivateNotesListPage.js @@ -1,19 +1,20 @@ +import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; +import React, {useEffect, useMemo} from 'react'; import {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {withNetwork} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import * as UserUtils from '@libs/UserUtils'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; @@ -57,6 +58,20 @@ const defaultProps = { function PrivateNotesListPage({report, personalDetailsList, session}) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const isFocused = useIsFocused(); + + useEffect(() => { + const navigateToEditPageTimeout = setTimeout(() => { + if (_.some(report.privateNotes, (item) => item.note) || !isFocused) { + return; + } + Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, session.accountID)); + }, CONST.ANIMATED_TRANSITION); + + return () => { + clearTimeout(navigateToEditPageTimeout); + }; + }, [report.privateNotes, report.reportID, session.accountID, isFocused]); /** * Gets the menu item for each workspace @@ -67,7 +82,6 @@ function PrivateNotesListPage({report, personalDetailsList, session}) { */ function getMenuItem(item, index) { const keyTitle = item.translationKey ? translate(item.translationKey) : item.title; - return ( - ); @@ -98,10 +114,10 @@ function PrivateNotesListPage({report, personalDetailsList, session}) { return _.chain(lodashGet(report, 'privateNotes', {})) .map((privateNote, accountID) => ({ title: Number(lodashGet(session, 'accountID', null)) === Number(accountID) ? translate('privateNotes.myNote') : lodashGet(personalDetailsList, [accountID, 'login'], ''), - icon: UserUtils.getAvatar(lodashGet(personalDetailsList, [accountID, 'avatar'], UserUtils.getDefaultAvatar(accountID)), accountID), - iconType: CONST.ICON_TYPE_AVATAR, - action: () => Navigation.navigate(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, accountID)), + action: () => Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, accountID)), brickRoadIndicator: privateNoteBrickRoadIndicator(accountID), + note: lodashGet(privateNote, 'note', ''), + disabled: Number(session.accountID) !== Number(accountID), })) .value(); }, [report, personalDetailsList, session, translate]); @@ -116,6 +132,7 @@ function PrivateNotesListPage({report, personalDetailsList, session}) { shouldShowBackButton onCloseButtonPress={() => Navigation.dismissModal()} /> + {translate('privateNotes.personalNoteMessage')} {_.map(privateNotes, (item, index) => getMenuItem(item, index))} ); @@ -132,6 +149,9 @@ export default compose( personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, + session: { + key: ONYXKEYS.SESSION, + }, }), withNetwork(), )(PrivateNotesListPage); diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index ffe8271629f4..97ec3f99da3c 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -254,7 +254,7 @@ function ProfilePage(props) { title={`${props.translate('privateNotes.title')}`} titleStyle={styles.flex1} icon={Expensicons.Pencil} - onPress={() => Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(props.report.reportID))} + onPress={() => ReportUtils.navigateToPrivateNotes(props.report, props.session)} wrapperStyle={styles.breakAll} shouldShowRightIcon brickRoadIndicator={Report.hasErrorInPrivateNotes(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 6d1a1e4db077..442276b19a0b 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -137,13 +137,13 @@ function ReportDetailsPage(props) { translationKey: 'privateNotes.title', icon: Expensicons.Pencil, isAnonymousAction: false, - action: () => Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(props.report.reportID)), + action: () => ReportUtils.navigateToPrivateNotes(props.report, props.session), brickRoadIndicator: Report.hasErrorInPrivateNotes(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', }); } return items; - }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, props.report, isGroupDMChat, isPolicyMember, isUserCreatedPolicyRoom]); + }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, props.report, isGroupDMChat, isPolicyMember, isUserCreatedPolicyRoom, props.session]); const displayNamesWithTooltips = useMemo(() => { const hasMultipleParticipants = participants.length > 1; @@ -256,5 +256,8 @@ export default compose( policies: { key: ONYXKEYS.COLLECTION.POLICY, }, + session: { + key: ONYXKEYS.SESSION, + }, }), )(ReportDetailsPage); diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 81a92c4bf603..2b84f3946bc3 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -139,6 +139,7 @@ type Report = { isChatRoom?: boolean; participantsList?: Array>; text?: string; + privateNotes?: Record; }; export default Report;