diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cd1d7ed0ed85..89472051283d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4838,6 +4838,10 @@ const CONST = { SUCCESS: 'success', ERROR: 'error', WARNING: 'warning', + POSITION: { + TOP: 'top', + BOTTOM_RIGHT: 'bottomRight', + }, DURATION: 2000, DURATION_LONG: 3500, // Longer duration for growls with an actionable button (e.g. "View"), giving the user enough time to tap it. @@ -4845,10 +4849,6 @@ const CONST = { // Pixel distance used to park the growl fully offscreen before it slides in. It only needs to // exceed the growl's height + margins; the exact value isn't tied to a measured dimension. OFFSCREEN_OFFSET: 255, - POSITION: { - TOP: 'top', - BOTTOM_RIGHT: 'bottomRight', - }, }, LOCALES, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 8afc6a92722c..f6e97ddc6a64 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4025,6 +4025,13 @@ const ROUTES = { getRoute: (policyID: string, cardID: string, feed: CompanyCardFeedWithDomainID) => `workspaces/${policyID}/company-cards/${encodeURIComponent(feed)}/${encodeURIComponent(cardID)}/edit/transaction-start-date` as const, }, + WORKSPACE_COMPANY_CARDS_BULK_EDIT_TRANSACTION_START_DATE: { + route: 'workspaces/:policyID/company-cards/:feed/bulk-edit/transaction-start-date', + getRoute: (policyID: string, feed: CompanyCardFeedWithDomainID, cardIDs?: string[]) => { + const cardIDsQuery = cardIDs?.length ? `?cardIDs=${encodeURIComponent(cardIDs.join(','))}` : ''; + return `workspaces/${policyID}/company-cards/${encodeURIComponent(feed)}/bulk-edit/transaction-start-date${cardIDsQuery}` as const; + }, + }, WORKSPACE_EXPENSIFY_CARD: { route: 'workspaces/:policyID/expensify-card', getRoute: (policyID: string | undefined) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 4c2acfce341f..54c6a82a38d3 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -831,6 +831,7 @@ const SCREENS = { COMPANY_CARD_ADD_WORK_EMAIL: 'Workspace_CompanyCard_Add_Work_Email', COMPANY_CARD_VERIFY_WORK_EMAIL: 'Workspace_CompanyCard_Verify_Work_Email', COMPANY_CARD_EDIT_TRANSACTION_START_DATE: 'Workspace_CompanyCard_Edit_Transaction_Start_Date', + COMPANY_CARDS_BULK_EDIT_TRANSACTION_START_DATE: 'Workspace_CompanyCards_Bulk_Edit_Transaction_Start_Date', DYNAMIC_COMPANY_CARD_EXPORT: 'Dynamic_Workspace_CompanyCard_Export', EXPENSIFY_CARD: 'Workspace_ExpensifyCard', DYNAMIC_EXPENSIFY_CARD_DETAILS: 'Dynamic_Workspace_ExpensifyCard_Details', diff --git a/src/components/Avatar/connected/AccountAvatarWithCardFeed.tsx b/src/components/Avatar/connected/AccountAvatarWithCardFeed.tsx index f22e789fcd9f..9cc4fa977d56 100644 --- a/src/components/Avatar/connected/AccountAvatarWithCardFeed.tsx +++ b/src/components/Avatar/connected/AccountAvatarWithCardFeed.tsx @@ -1,7 +1,7 @@ import SubscriptCardFeedAvatar from '@components/Avatar/layouts/SubscriptCardFeedAvatar'; import CONST from '@src/CONST'; -import type {CardFeed} from '@src/types/onyx/CardFeeds'; +import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -15,7 +15,7 @@ type AccountAvatarWithCardFeedProps = { accountID: number; /** Card feed to display as the subscript */ - cardFeed: CardFeed; + cardFeed: CardFeedWithNumber; /** Size of the subscript card feed icon */ cardFeedIconSize?: {width: number; height: number}; diff --git a/src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx b/src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx index 2a995abd6ecf..cca1de20c3c5 100644 --- a/src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx +++ b/src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx @@ -12,7 +12,7 @@ import {getCardFeedIcon} from '@libs/CardUtils'; import variables from '@styles/variables'; -import type {CardFeed} from '@src/types/onyx/CardFeeds'; +import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {Icon as IconType} from '@src/types/onyx/OnyxCommon'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -28,7 +28,7 @@ type SubscriptCardFeedAvatarProps = BaseAvatarProps & { primaryAvatar: IconType; /** Card feed to display as the subscript */ - cardFeed: CardFeed; + cardFeed: CardFeedWithNumber; cardFeedIconSize?: {width: number; height: number}; diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx index f8e1eaec7117..c80059f5a74c 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.tsx @@ -14,7 +14,11 @@ function GrowlNotificationContainer({children, progress, inactiveY, useBottomPos const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]); if (useBottomPosition) { - return {children}; + return ( + + {children} + + ); } return ( diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts index f08683f86554..a32e659ff7c5 100644 --- a/src/components/GrowlNotification/GrowlNotificationContainer/types.ts +++ b/src/components/GrowlNotification/GrowlNotificationContainer/types.ts @@ -9,7 +9,7 @@ type GrowlNotificationContainerProps = ChildrenProps & { /** Pixel offset that corresponds to the current "offscreen" position for the active anchor. */ inactiveY: number; - /** When true, anchor the growl to the bottom-right on wide screens. */ + /** When true, position the growl at the bottom-right. */ useBottomPosition?: boolean; }; diff --git a/src/components/GrowlNotification/GrowlNotificationContent.tsx b/src/components/GrowlNotification/GrowlNotificationContent.tsx index 76c0a5b1006f..598a593a8b49 100644 --- a/src/components/GrowlNotification/GrowlNotificationContent.tsx +++ b/src/components/GrowlNotification/GrowlNotificationContent.tsx @@ -23,6 +23,7 @@ import {scheduleOnRN} from 'react-native-worklets'; import GrowlNotificationContainer from './GrowlNotificationContainer'; import GrowlNotificationPortal from './GrowlNotificationPortal'; +import shouldUseExplicitBottomPosition from './shouldUseExplicitBottomPosition'; const INACTIVE_OFFSET = CONST.GROWL.OFFSCREEN_OFFSET; const INACTIVE_POSITION_Y = -INACTIVE_OFFSET; @@ -35,7 +36,7 @@ type GrowlNotificationContentProps = { duration: number; action?: GrowlAction; - /** Wide-screen anchor. Omit to keep the top anchor unless this growl has an action. */ + /** Position where the growl is anchored */ position?: GrowlPosition; /** Identifies this growl instance; passed back through onDismissed so the parent can ignore stale dismissals. */ @@ -67,8 +68,7 @@ function GrowlNotificationContent({bodyText, type, duration, action, position, n const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'Checkmark']); // Derived live so that resizing the window flips position + slide direction during display. - // Narrow layouts stay at the top. The bottom-right anchor is a toast the width of the sidebar on wide screens. - const useBottomPosition = !shouldUseNarrowLayout && (!!action || position === CONST.GROWL.POSITION.BOTTOM_RIGHT); + const useBottomPosition = shouldUseExplicitBottomPosition(position) || (!!action && !shouldUseNarrowLayout); const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y; const types: GrowlIconTypes = { diff --git a/src/components/GrowlNotification/index.tsx b/src/components/GrowlNotification/index.tsx index e0f0cf40163e..76f536475238 100644 --- a/src/components/GrowlNotification/index.tsx +++ b/src/components/GrowlNotification/index.tsx @@ -12,6 +12,8 @@ type GrowlContent = { type: GrowlType; duration: number; action?: GrowlAction; + + /** Position where the growl is anchored */ position?: GrowlPosition; /** Bumped on every show() call so identical-args re-shows still remount and re-trigger the growl. */ @@ -30,8 +32,8 @@ type GrowlNotificationProps = { function GrowlNotification({ref}: GrowlNotificationProps) { const [content, setContent] = useState(null); - const show = (text: string, growlType: GrowlType, duration: number, action?: GrowlAction, position?: GrowlPosition) => { - setContent((prev) => ({bodyText: text, type: growlType, duration, action, position, nonce: (prev?.nonce ?? 0) + 1})); + const show: NonNullable = ({bodyText, type, duration, action, position}) => { + setContent((prev) => ({bodyText, type, duration, action, position, nonce: (prev?.nonce ?? 0) + 1})); }; useImperativeHandle(ref, () => ({show}), [show]); diff --git a/src/components/GrowlNotification/shouldUseExplicitBottomPosition/index.native.ts b/src/components/GrowlNotification/shouldUseExplicitBottomPosition/index.native.ts new file mode 100644 index 000000000000..ea506e5904a4 --- /dev/null +++ b/src/components/GrowlNotification/shouldUseExplicitBottomPosition/index.native.ts @@ -0,0 +1,5 @@ +import type {GrowlPosition} from '@libs/Growl'; + +const shouldUseExplicitBottomPosition: (position?: GrowlPosition) => boolean = () => false; + +export default shouldUseExplicitBottomPosition; diff --git a/src/components/GrowlNotification/shouldUseExplicitBottomPosition/index.ts b/src/components/GrowlNotification/shouldUseExplicitBottomPosition/index.ts new file mode 100644 index 000000000000..e2c7af21f3e5 --- /dev/null +++ b/src/components/GrowlNotification/shouldUseExplicitBottomPosition/index.ts @@ -0,0 +1,12 @@ +import type {GrowlPosition} from '@libs/Growl'; + +import CONST from '@src/CONST'; + +// This file is used to determine if we should use an explicit bottom position for the growl notification. +// This is necessary for certain layouts where the growl notification may be obscured by other UI elements, such as a bottom tab bar or a floating action button. +// By using an explicit bottom position, we can ensure that the growl notification is always visible to the user. +function shouldUseExplicitBottomPosition(position?: GrowlPosition) { + return position === CONST.GROWL.POSITION.BOTTOM_RIGHT; +} + +export default shouldUseExplicitBottomPosition; diff --git a/src/components/SupportTicketResolvedFooter.tsx b/src/components/SupportTicketResolvedFooter.tsx index c40fecef4190..69d6ed776596 100644 --- a/src/components/SupportTicketResolvedFooter.tsx +++ b/src/components/SupportTicketResolvedFooter.tsx @@ -41,7 +41,7 @@ function SupportTicketResolvedFooter({reportID, isOffline, shouldShowReopenButto return; } - Growl.error(translate('supportTicket.noSupportRepAvailable')); + Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); }) .catch((error: unknown) => Log.hmmm('[SupportTicketResolvedFooter] Failed to reopen support ticket', {reportID, error})) diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx index ad26cfad9c22..7156dfa78ec2 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls.tsx @@ -17,8 +17,10 @@ import navigateToCardTransactions from '@libs/CardNavigationUtils'; import {formatMaskedCardName} from '@libs/CardUtils'; import escapeCsvField from '@libs/escapeCsvField'; import localFileDownload from '@libs/localFileDownload'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import {format, parseISO} from 'date-fns'; import React from 'react'; @@ -26,12 +28,13 @@ import {View} from 'react-native'; import type {WorkspaceCompanyCardTableItemData} from './WorkspaceCompanyCardsTableRow'; -type WorkspaceCompanyCardBulkActionType = 'unassign' | 'viewTransactions' | 'exportCSV'; +type WorkspaceCompanyCardBulkActionType = 'editTransactionStartDate' | 'unassign' | 'viewTransactions' | 'exportCSV'; type WorkspaceCompanyCardsTableControlsProps = { policyID: string; domainOrWorkspaceAccountID: number; bankName: UseCompanyCardsResult['bankName']; + feedName: UseCompanyCardsResult['feedName']; /** Whether the current member can edit company cards */ canWriteCompanyCards: boolean; @@ -47,6 +50,7 @@ function WorkspaceCompanyCardsTableControls({ policyID, domainOrWorkspaceAccountID, bankName, + feedName, canWriteCompanyCards, clearCardSelection, isSelectionModeEnabled, @@ -55,7 +59,7 @@ function WorkspaceCompanyCardsTableControls({ const {pageGutter} = useLayoutSpacing(); const {translate, getLocalDateFromDatetime} = useLocalize(); const {showConfirmModal} = useConfirmModal(); - const icons = useMemoizedLazyExpensifyIcons(['Export', 'MoneySearch', 'RemoveMembers']); + const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Export', 'MoneySearch', 'RemoveMembers']); const {processedData, shouldUseNarrowTableLayout} = useTableContext(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -139,6 +143,23 @@ function WorkspaceCompanyCardsTableControls({ clearCardSelection(); }; + const editSelectedCardTransactionStartDates = () => { + if (!feedName) { + return; + } + + const selectedCardIDs = selectedAssignedCards + .map((card) => card.assignedCard?.cardID) + .filter((cardID): cardID is number => cardID !== undefined) + .map(String); + const [selectedCardID] = selectedCardIDs; + if (!selectedCardID) { + return; + } + + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_BULK_EDIT_TRANSACTION_START_DATE.getRoute(policyID, feedName, selectedCardIDs)); + }; + const getBulkActionOptions = (): Array> => { const options: Array> = []; @@ -152,13 +173,20 @@ function WorkspaceCompanyCardsTableControls({ onSelected: confirmBulkUnassign, }); } - options.push({ icon: icons.MoneySearch, text: translate('workspace.common.viewTransactions'), value: 'viewTransactions', onSelected: viewSelectedCardTransactions, }); + if (canWriteCompanyCards) { + options.push({ + icon: icons.CalendarSolid, + text: translate('workspace.moreFeatures.companyCards.bulkUpdateTransactionStartDate'), + value: 'editTransactionStartDate', + onSelected: editSelectedCardTransactionStartDates, + }); + } } options.push({ diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx index 37f31ddc3b21..c537a9760d5b 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx @@ -12,7 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {formatMaskedCardName} from '@libs/CardUtils'; +import {formatMaskedCardName, getCompanyCardFeed} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -20,7 +20,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {Card, CompanyCardFeed, CompanyCardFeedWithDomainID} from '@src/types/onyx'; +import type {Card, CompanyCardFeedWithDomainID} from '@src/types/onyx'; import type {CardAssignmentData} from '@src/types/onyx/Card'; import {Str} from 'expensify-common'; @@ -86,7 +86,6 @@ function WorkspaceCompanyCardTableRow({ const isTableSemanticsEnabled = shouldUseTableSemantics(shouldUseNarrowTableLayout); const {cardName, encryptedCardNumber, customCardName, cardholder, assignedCard, isAssigned, errors, pendingAction, isCardDeleted, onDismissError} = item; - const formattedCustomCardName = customCardName ?? ''; const formattedCardDetails = formatMaskedCardName(cardName); const formattedCustomCardNameSuffix = formattedCustomCardName ? ` • ${formattedCustomCardName}` : ''; @@ -147,7 +146,7 @@ function WorkspaceCompanyCardTableRow({ {isAssigned ? ( { if (!bankName || cardID === undefined) { @@ -309,7 +318,14 @@ function WorkspaceCompanyCardsTable({ } showInlineEditError(renameCompanyCardInline(translate, domainOrWorkspaceAccountID, String(cardID), newName, bankName, customCardName, storedCardName)); }, - onDismissError: () => resetFailedWorkspaceCompanyCardUnassignment(domainOrWorkspaceAccountID, bankName, assignedCard?.cardID), + onDismissError: () => { + if (hasTransactionStartDateErrors && bankName && cardID !== undefined) { + clearCompanyCardErrorField(domainOrWorkspaceAccountID, String(cardID), bankName, 'scrapeMinDate', true); + } + if (hasVisibleCardErrors) { + resetFailedWorkspaceCompanyCardUnassignment(domainOrWorkspaceAccountID, bankName, assignedCard?.cardID); + } + }, }; }) .filter((item) => isOffline || item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); @@ -483,6 +499,7 @@ function WorkspaceCompanyCardsTable({ policyID={policyID} domainOrWorkspaceAccountID={domainOrWorkspaceAccountID} bankName={bankName} + feedName={feedName} canWriteCompanyCards={canWriteCompanyCards} clearCardSelection={clearCardSelection} isSelectionModeEnabled={isSelectionModeEnabled} diff --git a/src/languages/de.ts b/src/languages/de.ts index 5f8a2b4cd3c9..dde21adaa4fe 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6508,6 +6508,9 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU startDateDescription: 'Wähle dein Import-Startdatum. Wir synchronisieren alle Transaktionen ab diesem Datum.', editStartDateDescription: 'Wähle ein neues Startdatum für Transaktionen. Wir synchronisieren alle Transaktionen ab diesem Datum, mit Ausnahme derer, die wir bereits importiert haben.', + bulkEditStartDateDescription: + 'Wähle ein Startdatum für Transaktionen für alle ausgewählten Karten. Wir synchronisieren Transaktionen ab diesem Datum, mit Ausnahme derer, die wir bereits importiert haben.', + bulkStartDateUpdated: 'Startdatum für Transaktionen aktualisiert', fromTheBeginning: 'Von Anfang an', customStartDate: 'Benutzerdefiniertes Startdatum', customCloseDate: 'Benutzerdefiniertes Abschlussdatum', @@ -6848,6 +6851,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU `Wähle das ${integration}-Konto, in das Transaktionen exportiert werden sollen.${exportPageLink ? ` Wähle eine andere Exportoption, um die verfügbaren Konten zu ändern.` : ''}`, lastUpdated: 'Zuletzt aktualisiert', transactionStartDate: 'Transaktionsstartdatum', + bulkUpdateTransactionStartDate: 'Transaktionsstartdatum aktualisieren', updateCard: 'Karte aktualisieren', unassignCard: 'Karte zuweisen aufheben', unassignCards: 'Kartenzuweisung aufheben', diff --git a/src/languages/el.ts b/src/languages/el.ts index 05f8a99c394b..e7aa870d9057 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -6604,6 +6604,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ startDateDescription: 'Επιλέξτε την ημερομηνία έναρξης εισαγωγής. Θα συγχρονίσουμε όλες τις συναλλαγές από αυτήν την ημερομηνία και μετά.', editStartDateDescription: 'Επιλέξτε μια νέα ημερομηνία έναρξης συναλλαγών. Θα συγχρονίσουμε όλες τις συναλλαγές από αυτήν την ημερομηνία και μετά, εξαιρώντας όσες έχουμε ήδη εισαγάγει.', + bulkEditStartDateDescription: + 'Επιλέξτε ημερομηνία έναρξης συναλλαγών για όλες τις επιλεγμένες κάρτες. Θα συγχρονίσουμε τις συναλλαγές από αυτήν την ημερομηνία και μετά, εξαιρώντας όσες έχουμε ήδη εισαγάγει.', + bulkStartDateUpdated: 'Η ημερομηνία έναρξης συναλλαγών ενημερώθηκε', fromTheBeginning: 'Από την αρχή', customStartDate: 'Προσαρμοσμένη ημερομηνία έναρξης', customCloseDate: 'Προσαρμοσμένη ημερομηνία λήξης', @@ -6969,6 +6972,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ `Επιλέξτε τον λογαριασμό ${integration} στον οποίο θα εξαχθούν οι συναλλαγές.${exportPageLink ? `Επιλέξτε μια διαφορετική επιλογή εξαγωγής για να αλλάξετε τους διαθέσιμους λογαριασμούς.` : ''}`, lastUpdated: 'Τελευταία ενημέρωση', transactionStartDate: 'Ημερομηνία έναρξης συναλλαγής', + bulkUpdateTransactionStartDate: 'Ενημέρωση ημερομηνίας έναρξης συναλλαγής', updateCard: 'Ενημέρωση κάρτας', unassignCard: 'Αποδέσμευση κάρτας', unassignCards: 'Αποδέσμευση καρτών', diff --git a/src/languages/en.ts b/src/languages/en.ts index 55a23bbfbd3d..3c05df47f723 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6892,6 +6892,8 @@ const translations = { chooseTransactionStartDate: 'Choose a transaction start date', startDateDescription: "Choose your import start date. We'll sync all transactions from this date onwards.", editStartDateDescription: "Choose a new transaction start date. We'll sync all transactions from that date onwards, excluding those we already imported.", + bulkEditStartDateDescription: "Choose a transaction start date for all selected cards. We'll sync transactions from that date onwards, excluding those we already imported.", + bulkStartDateUpdated: 'Transaction start date updated', fromTheBeginning: 'From the beginning', customStartDate: 'Custom start date', customCloseDate: 'Custom close date', @@ -7235,6 +7237,7 @@ const translations = { `Choose the ${integration} account where transactions should be exported.${exportPageLink ? ` Select a different export option to change the available accounts.` : ''}`, lastUpdated: 'Last updated', transactionStartDate: 'Transaction start date', + bulkUpdateTransactionStartDate: 'Update transaction start date', updateCard: 'Update card', unassignCard: 'Unassign card', unassignCards: 'Unassign cards', diff --git a/src/languages/es.ts b/src/languages/es.ts index e491088e0ee7..6603fcc2fed4 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6357,6 +6357,9 @@ ${amount} para ${merchant} - ${date}`, startDateDescription: 'Importaremos todas las transacciones desde esta fecha en adelante. Si no se especifica una fecha, iremos tan atrás como lo permita tu banco.', editStartDateDescription: 'Elige una nueva fecha de inicio de transacciones. Sincronizaremos todas las transacciones a partir de esa fecha, excepto las que ya hayamos importado.', + bulkEditStartDateDescription: + 'Elige una fecha de inicio de transacciones para todas las tarjetas seleccionadas. Sincronizaremos las transacciones a partir de esa fecha, excepto las que ya hayamos importado.', + bulkStartDateUpdated: 'Fecha de inicio de transacciones actualizada', fromTheBeginning: 'Desde el principio', customStartDate: 'Fecha de inicio personalizada', customCloseDate: 'Fecha de cierre personalizada', @@ -6706,6 +6709,7 @@ ${amount} para ${merchant} - ${date}`, `Seleccione la cuenta ${integration} donde se deben exportar las transacciones.${exportPageLink ? ` Seleccione una cuenta diferente opción de exportación para cambiar las cuentas disponibles.` : ''}`, lastUpdated: 'Última actualización', transactionStartDate: 'Fecha de inicio de transacciones', + bulkUpdateTransactionStartDate: 'Actualizar fecha de inicio de transacciones', updateCard: 'Actualizar tarjeta', unassignCard: 'Desasignar tarjeta', unassignCards: 'Desasignar tarjetas', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b48b6cab62bc..4b20f8cd3508 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6513,6 +6513,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. startDateDescription: 'Choisissez votre date de début d’importation. Nous synchroniserons toutes les transactions à partir de cette date.', editStartDateDescription: 'Choisissez une nouvelle date de début de transaction. Nous synchroniserons toutes les transactions à partir de cette date, en excluant celles que nous avons déjà importées.', + bulkEditStartDateDescription: + 'Choisissez une date de début de transaction pour toutes les cartes sélectionnées. Nous synchroniserons les transactions à partir de cette date, en excluant celles que nous avons déjà importées.', + bulkStartDateUpdated: 'Date de début de transaction mise à jour', fromTheBeginning: 'Depuis le début', customStartDate: 'Date de début personnalisée', customCloseDate: 'Date de clôture personnalisée', @@ -6866,6 +6869,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. `Choisissez le compte ${integration} vers lequel les transactions doivent être exportées.${exportPageLink ? ` Sélectionnez une autre option d’exportation pour modifier les comptes disponibles.` : ''}`, lastUpdated: 'Dernière mise à jour', transactionStartDate: 'Date de début de la transaction', + bulkUpdateTransactionStartDate: 'Mettre à jour la date de début de la transaction', updateCard: 'Mettre à jour la carte', unassignCard: 'Retirer l’assignation de la carte', unassignCards: 'Retirer l’assignation des cartes', diff --git a/src/languages/it.ts b/src/languages/it.ts index 8669dd1065af..69a12560b02b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6459,6 +6459,9 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. chooseTransactionStartDate: 'Scegli una data di inizio transazione', startDateDescription: 'Scegli la data di inizio per l’importazione. Sincronizzeremo tutte le transazioni da questa data in poi.', editStartDateDescription: 'Scegli una nuova data di inizio transazione. Sincronizzeremo tutte le transazioni da quella data in poi, escludendo quelle già importate.', + bulkEditStartDateDescription: + 'Scegli una data di inizio transazione per tutte le carte selezionate. Sincronizzeremo le transazioni da quella data in poi, escludendo quelle già importate.', + bulkStartDateUpdated: 'Data di inizio transazione aggiornata', fromTheBeginning: "Dall'inizio", customStartDate: 'Data di inizio personalizzata', customCloseDate: 'Data di chiusura personalizzata', @@ -6809,6 +6812,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. `Scegli il conto ${integration} in cui esportare le transazioni.${exportPageLink ? ` Seleziona un’altra opzione di esportazione per modificare i conti disponibili.` : ''}`, lastUpdated: 'Ultimo aggiornamento', transactionStartDate: 'Data di inizio transazione', + bulkUpdateTransactionStartDate: 'Aggiorna data di inizio transazione', updateCard: 'Aggiorna carta', unassignCard: 'Rimuovi carta', unassignCards: 'Rimuovi carte', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index ed4b43097ac6..0bd97cf499fc 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6388,6 +6388,8 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO chooseTransactionStartDate: '取引の開始日を選択', startDateDescription: 'インポート開始日を選択してください。この日付以降のすべての取引を同期します。', editStartDateDescription: '新しい取引開始日を選択してください。その日以降のすべての取引を、すでに取り込んだものを除いて同期します。', + bulkEditStartDateDescription: '選択したすべてのカードの取引開始日を選択してください。その日以降の取引を、すでに取り込んだものを除いて同期します。', + bulkStartDateUpdated: '取引開始日を更新しました', fromTheBeginning: '最初から', customStartDate: 'カスタム開始日', customCloseDate: 'カスタム締め日', @@ -6719,6 +6721,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO `取引をエクスポートする${integration}アカウントを選択してください。${exportPageLink ? `利用可能なアカウントを変更するには、別のエクスポートオプションを選択してください。` : ''}`, lastUpdated: '最終更新日時', transactionStartDate: '取引開始日', + bulkUpdateTransactionStartDate: '取引開始日を更新', updateCard: 'カードを更新', unassignCard: 'カードの割り当てを解除', unassignCards: 'カードの割り当てを解除', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0543602f1d02..0c5c4666ff94 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6454,6 +6454,9 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ startDateDescription: 'Kies je importstartdatum. We synchroniseren alle transacties vanaf deze datum.', editStartDateDescription: 'Kies een nieuwe startdatum voor transacties. We synchroniseren alle transacties vanaf die datum, met uitzondering van transacties die we al hebben geïmporteerd.', + bulkEditStartDateDescription: + 'Kies een startdatum voor transacties voor alle geselecteerde kaarten. We synchroniseren transacties vanaf die datum, met uitzondering van transacties die we al hebben geïmporteerd.', + bulkStartDateUpdated: 'Startdatum voor transacties bijgewerkt', fromTheBeginning: 'Vanaf het begin', customStartDate: 'Aangepaste startdatum', customCloseDate: 'Aangepaste sluitingsdatum', @@ -6795,6 +6798,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ `Kies de ${integration}-rekening waarnaar transacties moeten worden geëxporteerd.${exportPageLink ? ` Selecteer een andere exportoptie om de beschikbare rekeningen te wijzigen.` : ''}`, lastUpdated: 'Laatst bijgewerkt', transactionStartDate: 'Begindatum transactie', + bulkUpdateTransactionStartDate: 'Begindatum transactie bijwerken', updateCard: 'Kaart bijwerken', unassignCard: 'Kaart loskoppelen', unassignCards: 'Kaarten loskoppelen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a79b25c541ae..85256b225cd6 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6479,6 +6479,9 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy chooseTransactionStartDate: 'Wybierz datę początkową transakcji', startDateDescription: 'Wybierz datę rozpoczęcia importu. Zsynchronizujemy wszystkie transakcje od tego dnia.', editStartDateDescription: 'Wybierz nową datę początkową transakcji. Zsynchronizujemy wszystkie transakcje od tej daty wzwyż, z wyłączeniem tych, które już zaimportowaliśmy.', + bulkEditStartDateDescription: + 'Wybierz datę początkową transakcji dla wszystkich wybranych kart. Zsynchronizujemy transakcje od tej daty wzwyż, z wyłączeniem tych, które już zaimportowaliśmy.', + bulkStartDateUpdated: 'Zaktualizowano datę początkową transakcji', fromTheBeginning: 'Od początku', customStartDate: 'Niestandardowa data początkowa', customCloseDate: 'Niestandardowa data zamknięcia', @@ -6820,6 +6823,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy `Wybierz konto ${integration}, do którego mają być eksportowane transakcje.${exportPageLink ? ` Wybierz inną opcję eksportu, aby zmienić dostępne konta.` : ''}`, lastUpdated: 'Ostatnia aktualizacja', transactionStartDate: 'Data początkowa transakcji', + bulkUpdateTransactionStartDate: 'Zaktualizuj datę początkową transakcji', updateCard: 'Zaktualizuj kartę', unassignCard: 'Cofnij przypisanie karty', unassignCards: 'Cofnij przypisanie kart', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 597f30dd1438..502d311c9468 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6453,6 +6453,9 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS chooseTransactionStartDate: 'Escolha uma data de início da transação', startDateDescription: 'Escolha a data inicial de importação. Vamos sincronizar todas as transações a partir desta data.', editStartDateDescription: 'Escolha uma nova data de início de transações. Vamos sincronizar todas as transações a partir dessa data, excluindo as que já importamos.', + bulkEditStartDateDescription: + 'Escolha uma data de início de transações para todos os cartões selecionados. Vamos sincronizar as transações a partir dessa data, excluindo as que já importamos.', + bulkStartDateUpdated: 'Data de início de transações atualizada', fromTheBeginning: 'Desde o início', customStartDate: 'Data de início personalizada', customCloseDate: 'Data de fechamento personalizada', @@ -6797,6 +6800,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS `Escolha a conta do ${integration} para a qual as transações devem ser exportadas.${exportPageLink ? ` Selecione uma opção de exportação diferente para alterar as contas disponíveis.` : ''}`, lastUpdated: 'Última atualização', transactionStartDate: 'Data de início da transação', + bulkUpdateTransactionStartDate: 'Atualizar data de início da transação', updateCard: 'Atualizar cartão', unassignCard: 'Remover cartão', unassignCards: 'Remover cartões', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index bc038bbd09ab..83f4041ea1b2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6238,6 +6238,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM chooseTransactionStartDate: '选择交易开始日期', startDateDescription: '选择导入起始日期。我们会从该日期起同步所有交易。', editStartDateDescription: '选择新的交易起始日期。我们会从该日期开始同步所有交易,但不包括已经导入的交易。', + bulkEditStartDateDescription: '为所有选中的卡片选择交易起始日期。我们会从该日期开始同步交易,但不包括已经导入的交易。', + bulkStartDateUpdated: '交易起始日期已更新', fromTheBeginning: '从头开始', customStartDate: '自定义开始日期', customCloseDate: '自定义关闭日期', @@ -6552,6 +6554,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM `选择要导出交易的 ${integration} 账户。${exportPageLink ? `选择其他导出选项以更改可用账户。` : ''}`, lastUpdated: '最近更新', transactionStartDate: '交易开始日期', + bulkUpdateTransactionStartDate: '更新交易开始日期', updateCard: '更新卡片', unassignCard: '取消分配卡片', unassignCards: '取消分配卡片', diff --git a/src/libs/API/parameters/BulkUpdateCardTransactionStartDateParams.ts b/src/libs/API/parameters/BulkUpdateCardTransactionStartDateParams.ts new file mode 100644 index 000000000000..f1456bfa89c7 --- /dev/null +++ b/src/libs/API/parameters/BulkUpdateCardTransactionStartDateParams.ts @@ -0,0 +1,9 @@ +type BulkUpdateCardTransactionStartDateParams = { + /** JSON-encoded array of card IDs */ + cardIDs: string; + + /** Earliest date to import transactions from, or an empty string to import from the beginning */ + startDate: string; +}; + +export default BulkUpdateCardTransactionStartDateParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f139bf794897..ba2c3402a1a2 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -583,6 +583,7 @@ export type {default as UnassignCardParams} from './UnassignCardParams'; export type {default as SyncCardParams} from './SyncCardParams'; export type {default as UpdateCompanyCardNameParams} from './UpdateCompanyCardNameParams'; export type {default as UpdateCardTransactionStartDateParams} from './UpdateCardTransactionStartDateParams'; +export type {default as BulkUpdateCardTransactionStartDateParams} from './BulkUpdateCardTransactionStartDateParams'; export type {default as SetPersonalCardReimbursableParams} from './SetPersonalCardReimbursableParams'; export type {default as SetCompanyCardExportAccountParams} from './SetCompanyCardExportAccountParams'; export type {default as SetPersonalDetailsAndShipExpensifyCardsParams} from './SetPersonalDetailsAndShipExpensifyCardsParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 2c696914e02a..706a5927b85e 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -719,6 +719,7 @@ const WRITE_COMMANDS = { SYNC_CARD: 'SyncCard', UPDATE_COMPANY_CARD_NAME: 'SetCardName', UPDATE_CARD_TRANSACTION_START_DATE: 'UpdateCardTransactionStartDate', + BULK_UPDATE_CARD_TRANSACTION_START_DATE: 'BulkUpdateCardTransactionStartDate', SET_PERSONAL_CARD_REIMBURSABLE: 'SetPersonalCardReimbursable', SET_CARD_EXPORT_ACCOUNT: 'SetCardExportAccount', SET_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARDS: 'SetPersonalDetailsAndShipExpensifyCards', @@ -884,6 +885,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SYNC_CARD]: Parameters.SyncCardParams; [WRITE_COMMANDS.UPDATE_COMPANY_CARD_NAME]: Parameters.UpdateCompanyCardNameParams; [WRITE_COMMANDS.UPDATE_CARD_TRANSACTION_START_DATE]: Parameters.UpdateCardTransactionStartDateParams; + [WRITE_COMMANDS.BULK_UPDATE_CARD_TRANSACTION_START_DATE]: Parameters.BulkUpdateCardTransactionStartDateParams; [WRITE_COMMANDS.SET_PERSONAL_CARD_REIMBURSABLE]: Parameters.SetPersonalCardReimbursableParams; [WRITE_COMMANDS.SET_CARD_EXPORT_ACCOUNT]: Parameters.SetCompanyCardExportAccountParams; [WRITE_COMMANDS.SET_COMPANY_CARD_TRANSACTION_LIABILITY]: Parameters.SetCompanyCardTransactionLiability; diff --git a/src/libs/Growl.ts b/src/libs/Growl.ts index c5942f68c546..ea6c9adad45d 100644 --- a/src/libs/Growl.ts +++ b/src/libs/Growl.ts @@ -11,18 +11,23 @@ type GrowlAction = { /** The set of growl variants the notification UI knows how to render. */ type GrowlType = typeof CONST.GROWL.SUCCESS | typeof CONST.GROWL.ERROR | typeof CONST.GROWL.WARNING; - -/** Wide-screen anchor. Narrow layouts keep the top anchor. */ type GrowlPosition = ValueOf; type GrowlOptions = { + bodyText: string; duration?: number; action?: GrowlAction; + + /** Position where the growl is anchored */ position?: GrowlPosition; }; +type GrowlShowOptions = GrowlOptions & { + type: GrowlType; +}; + type GrowlRef = { - show?: (bodyText: string, type: GrowlType, duration: number, action?: GrowlAction, position?: GrowlPosition) => void; + show?: (options: GrowlShowOptions & {duration: number}) => void; }; const growlRef = React.createRef(); @@ -41,29 +46,29 @@ function setIsReady() { /** * Show the growl notification */ -function show(bodyText: string, type: GrowlType, duration?: number, action?: GrowlAction, position?: GrowlPosition) { +function show({bodyText, type, duration, action, position}: GrowlShowOptions) { // Default to a longer duration when there's an action button so users have time to tap it. const resolvedDuration = duration ?? (action ? CONST.GROWL.DURATION_WITH_ACTION : CONST.GROWL.DURATION); isReadyPromise.then(() => { if (!growlRef?.current?.show) { return; } - growlRef.current.show(bodyText, type, resolvedDuration, action, position); + growlRef.current.show({bodyText, type, duration: resolvedDuration, action, position}); }); } /** * Show error growl. */ -function error(bodyText: string, options?: GrowlOptions) { - show(bodyText, CONST.GROWL.ERROR, options?.duration, options?.action, options?.position); +function error(options: GrowlOptions) { + show({...options, type: CONST.GROWL.ERROR}); } /** * Show success growl */ -function success(bodyText: string, duration?: number, action?: GrowlAction) { - show(bodyText, CONST.GROWL.SUCCESS, duration, action); +function success(options: GrowlOptions) { + show({...options, type: CONST.GROWL.SUCCESS}); } export default { @@ -72,6 +77,6 @@ export default { success, }; -export type {GrowlRef, GrowlAction, GrowlType, GrowlPosition}; +export type {GrowlRef, GrowlAction, GrowlPosition, GrowlType}; export {growlRef, setIsReady}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index bd77d377f635..859dea8c18c6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1116,6 +1116,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardEditCardNamePage').default, [SCREENS.WORKSPACE.COMPANY_CARD_EDIT_TRANSACTION_START_DATE]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage').default, + [SCREENS.WORKSPACE.COMPANY_CARDS_BULK_EDIT_TRANSACTION_START_DATE]: () => + require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsBulkEditTransactionStartDatePage').default, [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT]: () => require('../../../../pages/workspace/companyCards/DynamicWorkspaceCompanyCardAccountSelectCardPage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW]: () => require('../../../../pages/workspace/expensifyCard/issueNew/DynamicIssueNewCardPage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE]: () => diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index b8eae5c610ad..e9167b115687 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -325,7 +325,7 @@ function createOnyxTabNavigator(names: ReadonlyAr .then(() => guard.onDiscard()) .catch((error: unknown) => { Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); - Growl.error(translate('common.genericErrorMessage')); + Growl.error({bodyText: translate('common.genericErrorMessage')}); }) .then(() => { jumpToTargetTab(); diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index 3c3912974e98..75dbbf8fcf53 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -128,6 +128,7 @@ const oldRoutes: Record = { '/workspaces/*/company-cards/*/refresh-card-feed-connection': '/workspaces/$1/company-cards/$2/refresh-card-feed-connection', '/workspaces/*/company-cards/*/work-email': '/workspaces/$1/company-cards/$2/work-email', '/workspaces/*/company-cards/*/verify-work-email': '/workspaces/$1/company-cards/$2/verify-work-email', + '/workspaces/*/company-cards/*/bulk-edit/transaction-start-date': '/workspaces/$1/company-cards/$2/bulk-edit/transaction-start-date', '/workspaces/*/company-cards/settings/*': '/workspaces/$1/company-cards/settings/$2', '/workspaces/*/company-cards/add-card-feed/*': '/workspaces/$1/company-cards/add-card-feed/$2', '/workspaces/*/company-cards/select-feed/*': '/workspaces/$1/company-cards/select-feed/$2', diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 62623bcbfc5e..f66b6f243060 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -454,6 +454,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.COMPANY_CARD_EDIT_TRANSACTION_START_DATE]: { path: ROUTES.WORKSPACE_COMPANY_CARD_EDIT_TRANSACTION_START_DATE.route, }, + [SCREENS.WORKSPACE.COMPANY_CARDS_BULK_EDIT_TRANSACTION_START_DATE]: { + path: ROUTES.WORKSPACE_COMPANY_CARDS_BULK_EDIT_TRANSACTION_START_DATE.route, + }, [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT]: DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_EXPORT.path, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW]: DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.path, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE]: DYNAMIC_ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_VALIDATE_CODE.path, diff --git a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts index feb4bd3572ca..8da6fb83a95c 100644 --- a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts +++ b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts @@ -1293,9 +1293,14 @@ type WorkspaceSettingsParamList = { }; [SCREENS.WORKSPACE.COMPANY_CARD_EDIT_TRANSACTION_START_DATE]: { policyID: string; - feed: string; + feed: CompanyCardFeedWithDomainID; cardID: string; }; + [SCREENS.WORKSPACE.COMPANY_CARDS_BULK_EDIT_TRANSACTION_START_DATE]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + cardIDs?: string; + }; [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT]: { policyID: string; cardID: string; diff --git a/src/libs/actions/CompanyCards.ts b/src/libs/actions/CompanyCards.ts index 0a99e6991617..2149d9b79625 100644 --- a/src/libs/actions/CompanyCards.ts +++ b/src/libs/actions/CompanyCards.ts @@ -5,6 +5,7 @@ import type {CombinedCardFeeds} from '@hooks/useCardFeeds'; import * as API from '@libs/API'; import type { AssignCompanyCardParams, + BulkUpdateCardTransactionStartDateParams, GetExpensifyCardStatementPDFParams, ImportCSVCompanyCardsParams, OpenPolicyAddCardFeedPageParams, @@ -894,7 +895,7 @@ function updateCardTransactionStartDate(domainOrWorkspaceAccountID: number, card key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainOrWorkspaceAccountID}_${bankName}`, value: { [cardID]: { - scrapeMinDate: oldStartDate, + scrapeMinDate: oldStartDate ?? null, pendingFields: { scrapeMinDate: null, }, @@ -918,6 +919,79 @@ function updateCardTransactionStartDate(domainOrWorkspaceAccountID: number, card }); } +function bulkUpdateCardTransactionStartDate( + domainOrWorkspaceAccountID: number, + bankName: CompanyCardFeedWithNumber, + cards: Array<{cardID: string; oldStartDate?: string}>, + newStartDate: string, +) { + const optimisticCards: Record> = {}; + const finallyCards: Record> = {}; + const failureCards: Record> = {}; + for (const {cardID, oldStartDate} of cards) { + optimisticCards[cardID] = { + scrapeMinDate: newStartDate, + pendingFields: { + scrapeMinDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: {scrapeMinDate: null}, + }; + finallyCards[cardID] = { + pendingFields: {scrapeMinDate: null}, + }; + failureCards[cardID] = { + scrapeMinDate: oldStartDate ?? null, + pendingFields: {scrapeMinDate: null}, + errorFields: { + scrapeMinDate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }; + } + + const key = `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainOrWorkspaceAccountID}_${bankName}` as const; + const optimisticData: Array> = [{onyxMethod: Onyx.METHOD.MERGE, key, value: optimisticCards}]; + const finallyData: Array> = [{onyxMethod: Onyx.METHOD.MERGE, key, value: finallyCards}]; + const failureData: Array> = [{onyxMethod: Onyx.METHOD.MERGE, key, value: failureCards}]; + const bulkTransactionStartDateUpdateID = rand64(); + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE}${domainOrWorkspaceAccountID}`, + value: {bulkTransactionStartDateUpdateSuccessID: bulkTransactionStartDateUpdateID}, + }, + ]; + + const parameters: BulkUpdateCardTransactionStartDateParams = { + cardIDs: JSON.stringify(cards.map(({cardID}) => Number(cardID))), + startDate: newStartDate, + }; + + const request = API.write(WRITE_COMMANDS.BULK_UPDATE_CARD_TRANSACTION_START_DATE, parameters, { + optimisticData, + successData, + finallyData, + failureData, + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE}${domainOrWorkspaceAccountID}`, { + bulkTransactionStartDateUpdateID, + }); + + return request; +} + +function clearBulkUpdateTransactionStartDateUpdate(domainOrWorkspaceAccountID: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE}${domainOrWorkspaceAccountID}`, { + bulkTransactionStartDateUpdateID: null, + }); +} + +function clearBulkUpdateTransactionStartDateSuccess(domainOrWorkspaceAccountID: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE}${domainOrWorkspaceAccountID}`, { + bulkTransactionStartDateUpdateSuccessID: null, + }); +} + function setCompanyCardExportAccount(policyID: string, domainOrWorkspaceAccountID: number, cardID: string, accountKey: string, newAccount: string, bank: CompanyCardFeedWithNumber) { const optimisticData: Array> = [ { @@ -1499,6 +1573,9 @@ export { updateWorkspaceCompanyCard, updateCompanyCardName, updateCardTransactionStartDate, + bulkUpdateCardTransactionStartDate, + clearBulkUpdateTransactionStartDateUpdate, + clearBulkUpdateTransactionStartDateSuccess, setCompanyCardExportAccount, clearCompanyCardErrorField, setAddNewCompanyCardStepAndData, diff --git a/src/libs/showInlineEditError.ts b/src/libs/showInlineEditError.ts index cf154abb9d28..623e0d393f1f 100644 --- a/src/libs/showInlineEditError.ts +++ b/src/libs/showInlineEditError.ts @@ -11,7 +11,7 @@ function showInlineEditError(message: string | undefined) { return; } - Growl.error(message, {position: CONST.GROWL.POSITION.BOTTOM_RIGHT}); + Growl.error({bodyText: message, position: CONST.GROWL.POSITION.BOTTOM_RIGHT}); } export default showInlineEditError; diff --git a/src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx b/src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx index 3feaac42180c..75148d32ad6b 100644 --- a/src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx +++ b/src/pages/EnablePayments/Pay/Onfido/OnfidoStep.tsx @@ -51,7 +51,7 @@ function OnfidoStep() { }, []); const reportError = useCallback(() => { - Growl.error(translate('onfidoStep.genericError'), {duration: 10000}); + Growl.error({bodyText: translate('onfidoStep.genericError'), duration: 10000}); }, [translate]); const verifyIdentity = useCallback( diff --git a/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx b/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx index 9c5c254eb16a..78468ee4a3d5 100644 --- a/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx +++ b/src/pages/EnablePayments/Wallet/VerifyIdentity/VerifyIdentity.tsx @@ -54,7 +54,7 @@ function VerifyIdentity() { const shouldShowOnfido = walletOnfidoData?.hasAcceptedPrivacyPolicy && !walletOnfidoData?.isLoading && !walletOnfidoData?.errors && !!walletOnfidoData?.sdkToken; const handleOnfidoError = () => { - Growl.error(translate('onfidoStep.genericError'), {duration: ONFIDO_ERROR_DISPLAY_DURATION}); + Growl.error({bodyText: translate('onfidoStep.genericError'), duration: ONFIDO_ERROR_DISPLAY_DURATION}); }; const goBack = () => { diff --git a/src/pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity.tsx b/src/pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity.tsx index 12a89bc0912a..8406f9360ae3 100644 --- a/src/pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity.tsx +++ b/src/pages/ReimbursementAccount/USD/Requestor/VerifyIdentity/VerifyIdentity.tsx @@ -61,7 +61,7 @@ function VerifyIdentity({onBackButtonPress, onSubmit}: VerifyIdentityProps) { const handleOnfidoError = () => { // In case of any unexpected error we log it to the server, show a growl, and return the user back to the requestor step so they can try again. - Growl.error(translate('onfidoStep.genericError'), {duration: ONFIDO_ERROR_DISPLAY_DURATION}); + Growl.error({bodyText: translate('onfidoStep.genericError'), duration: ONFIDO_ERROR_DISPLAY_DURATION}); clearOnfidoToken(); onBackButtonPress(); }; diff --git a/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx b/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx index 464a9cb4bb8c..88c44762570c 100644 --- a/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx +++ b/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx @@ -76,7 +76,7 @@ function DynamicReportDetailsShareReportPage({report, policy}: DynamicReportDeta startWithLoading(() => { shareReport(report.reportID, selectedLogin); Navigation.goBack(backPath); - Growl.success(translate('common.reportShared')); + Growl.success({bodyText: translate('common.reportShared')}); }); }; diff --git a/src/pages/settings/HelpPage/HelpPage.tsx b/src/pages/settings/HelpPage/HelpPage.tsx index 97a6d58d298b..e43420589013 100644 --- a/src/pages/settings/HelpPage/HelpPage.tsx +++ b/src/pages/settings/HelpPage/HelpPage.tsx @@ -73,7 +73,7 @@ function HelpPage() { return; } - Growl.error(translate('supportTicket.noSupportRepAvailable')); + Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); }) .catch(() => undefined); diff --git a/src/pages/workspace/companyCards/CompanyCardTransactionStartDateForm.tsx b/src/pages/workspace/companyCards/CompanyCardTransactionStartDateForm.tsx new file mode 100644 index 000000000000..da6dc2cde479 --- /dev/null +++ b/src/pages/workspace/companyCards/CompanyCardTransactionStartDateForm.tsx @@ -0,0 +1,164 @@ +import Button from '@components/Button'; +import DatePicker from '@components/DatePicker'; +import FormHelpMessage from '@components/FormHelpMessage'; +import Header from '@components/Header'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isRequiredFulfilled} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +import {format} from 'date-fns'; +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type DateOption = ValueOf; + +type CompanyCardTransactionStartDateFormProps = { + /** Supporting copy shown below the page header */ + description: string; + + /** Date option selected before the user makes a local selection */ + initialDateOption: DateOption | ''; + + /** Date shown by the custom date picker on initial render */ + initialStartDate: string; + + /** Called when the header back button is pressed */ + onBackButtonPress: () => void; + + /** Called with the selected date, or an empty string for transactions from the beginning */ + onSubmit: (startDate: string) => void; + + /** Identifier passed to the screen wrapper */ + testID: string; + + /** Whether the save button is disabled */ + isSaveDisabled?: boolean; + + /** Whether selecting the custom option resets its value to today's date */ + shouldResetStartDateOnCustomSelection?: boolean; +}; + +function CompanyCardTransactionStartDateForm({ + description, + initialDateOption, + initialStartDate, + onBackButtonPress, + onSubmit, + testID, + isSaveDisabled = false, + shouldResetStartDateOnCustomSelection = false, +}: CompanyCardTransactionStartDateFormProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [localDateOption, setLocalDateOption] = useState(undefined); + const dateOptionSelected = localDateOption ?? initialDateOption; + const [startDate, setStartDate] = useState(initialStartDate); + const [errorText, setErrorText] = useState(''); + + const handleSelectDateOption = (dateOption: DateOption) => { + setErrorText(''); + setLocalDateOption(dateOption); + if (dateOption !== CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM || !shouldResetStartDateOnCustomSelection) { + return; + } + setStartDate(format(new Date(), CONST.DATE.FNS_FORMAT_STRING)); + }; + + const submit = () => { + if (!isRequiredFulfilled(dateOptionSelected)) { + setErrorText(translate('common.error.fieldRequired')); + return; + } + + if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && !isRequiredFulfilled(startDate)) { + setErrorText(translate('common.error.fieldRequired')); + return; + } + + onSubmit(dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING ? '' : startDate); + }; + + const dateOptions = [ + { + value: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, + text: translate('workspace.companyCards.fromTheBeginning'), + keyForList: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, + isSelected: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, + }, + { + value: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, + text: translate('workspace.companyCards.customStartDate'), + keyForList: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, + isSelected: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, + }, + ]; + + return ( + +
+ + +
+ {description} + + handleSelectDateOption(value)} + data={dateOptions} + shouldSingleExecuteRowSelect + initiallyFocusedItemKey={dateOptionSelected} + shouldUpdateFocusedIndex + addBottomSafeAreaPadding + footerContent={ + + } + listFooterContent={ + dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM ? ( + + { + setErrorText(isRequiredFulfilled(value) ? '' : translate('common.error.fieldRequired')); + setStartDate(value); + }} + minDate={CONST.CALENDAR_PICKER.MIN_DATE} + errorText={errorText} + /> + + ) : ( + + ) + } + /> + +
+ ); +} + +export default CompanyCardTransactionStartDateForm; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx index ef3e2833d584..f5ab9617ec18 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx @@ -1,21 +1,11 @@ -import Button from '@components/Button'; -import DatePicker from '@components/DatePicker'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import Text from '@components/Text'; - import useCardFeeds from '@hooks/useCardFeeds'; import useCardsList from '@hooks/useCardsList'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; -import useThemeStyles from '@hooks/useThemeStyles'; import {getCompanyCardFeed, getCompanyFeeds, getDomainOrWorkspaceAccountID} from '@libs/CardUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {isRequiredFulfilled} from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; @@ -27,24 +17,22 @@ import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; -import type {ValueOf} from 'type-fest'; - import {format, parseISO} from 'date-fns'; -import React, {useState} from 'react'; -import {View} from 'react-native'; +import React from 'react'; +import CompanyCardTransactionStartDateForm from './CompanyCardTransactionStartDateForm'; import {getCompanyCardDetailsBackPath} from './utils'; -type DateOption = ValueOf; type WorkspaceCompanyCardEditTransactionStartDatePageProps = PlatformStackScreenProps; function WorkspaceCompanyCardEditTransactionStartDatePage({route, navigation}: WorkspaceCompanyCardEditTransactionStartDatePageProps) { const {policyID, cardID} = route.params; + // The linking layer can provide the path parameter with the feed separator still encoded. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- decoding preserves the company-feed shape guaranteed by this route const feedName = decodeURIComponent(route.params.feed) as CompanyCardFeedWithDomainID; const bank = getCompanyCardFeed(feedName); const {translate} = useLocalize(); - const styles = useThemeStyles(); const policy = usePolicy(policyID); const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; @@ -57,58 +45,12 @@ function WorkspaceCompanyCardEditTransactionStartDatePage({route, navigation}: W const card = allBankCards?.[cardID]; const currentStartDate = card?.scrapeMinDate; const savedDateOption = currentStartDate ? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM : CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING; - - const [localDateOption, setLocalDateOption] = useState(undefined); - const dateOptionSelected = localDateOption ?? savedDateOption; - - const [startDate, setStartDate] = useState(() => { - if (currentStartDate) { - return format(parseISO(currentStartDate), CONST.DATE.FNS_FORMAT_STRING); - } - return format(new Date(), CONST.DATE.FNS_FORMAT_STRING); - }); - - const [errorText, setErrorText] = useState(''); - - const handleSelectDateOption = (dateOption: DateOption) => { - setErrorText(''); - setLocalDateOption(dateOption); - if (dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING) { - return; - } - // Reset to current date when switching to custom - if (!currentStartDate) { - setStartDate(format(new Date(), CONST.DATE.FNS_FORMAT_STRING)); - } - }; - - const submit = () => { - if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && !isRequiredFulfilled(startDate)) { - setErrorText(translate('common.error.fieldRequired')); - return; - } - - const newStartDate = dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING ? '' : startDate; - + const initialStartDate = currentStartDate ? format(parseISO(currentStartDate), CONST.DATE.FNS_FORMAT_STRING) : format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + const submit = (newStartDate: string) => { updateCardTransactionStartDate(domainOrWorkspaceAccountID, cardID, newStartDate, bank, currentStartDate); goBackToCardDetails(); }; - const dateOptions = [ - { - value: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, - text: translate('workspace.companyCards.fromTheBeginning'), - keyForList: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, - isSelected: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING, - }, - { - value: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, - text: translate('workspace.companyCards.customStartDate'), - keyForList: CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, - isSelected: dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM, - }, - ]; - return ( - - - {translate('workspace.companyCards.editStartDateDescription')} - - handleSelectDateOption(value)} - data={dateOptions} - shouldSingleExecuteRowSelect - initiallyFocusedItemKey={dateOptionSelected} - shouldUpdateFocusedIndex - addBottomSafeAreaPadding - footerContent={ - - } - listFooterContent={ - dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM ? ( - - { - if (!isRequiredFulfilled(value)) { - setErrorText(translate('common.error.fieldRequired')); - } else { - setErrorText(''); - } - setStartDate(value); - }} - minDate={CONST.CALENDAR_PICKER.MIN_DATE} - errorText={errorText} - /> - - ) : null - } - /> - - + description={translate('workspace.companyCards.editStartDateDescription')} + initialDateOption={savedDateOption} + initialStartDate={initialStartDate} + onBackButtonPress={goBackToCardDetails} + onSubmit={submit} + shouldResetStartDateOnCustomSelection={!currentStartDate} + /> ); } diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsBulkEditTransactionStartDatePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsBulkEditTransactionStartDatePage.tsx new file mode 100644 index 000000000000..04696085ecad --- /dev/null +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsBulkEditTransactionStartDatePage.tsx @@ -0,0 +1,101 @@ +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsList from '@hooks/useCardsList'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; + +import {getCompanyCardFeed, getCompanyFeeds, getDomainOrWorkspaceAccountID} from '@libs/CardUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import Navigation from '@navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import {bulkUpdateCardTransactionStartDate} from '@userActions/CompanyCards'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import {format, parseISO} from 'date-fns'; +import React from 'react'; + +import CompanyCardTransactionStartDateForm from './CompanyCardTransactionStartDateForm'; + +type WorkspaceCompanyCardsBulkEditTransactionStartDatePageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.COMPANY_CARDS_BULK_EDIT_TRANSACTION_START_DATE +>; + +function WorkspaceCompanyCardsBulkEditTransactionStartDatePage({route}: WorkspaceCompanyCardsBulkEditTransactionStartDatePageProps) { + const {policyID} = route.params; + // The linking layer can provide the path parameter with the feed separator still encoded. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- decoding preserves the company-feed shape guaranteed by this route + const feedName = decodeURIComponent(route.params.feed) as CompanyCardFeedWithDomainID; + const cardIDs = (route.params.cardIDs ?? '').split(',').filter(Boolean); + const bank = getCompanyCardFeed(feedName); + + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; + + const [cardFeeds, cardFeedsMetadata] = useCardFeeds(policyID); + const companyFeeds = getCompanyFeeds(cardFeeds); + const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, companyFeeds[feedName]); + const [allBankCards, cardsMetadata] = useCardsList(feedName); + const isLoading = isLoadingOnyxValue(cardFeedsMetadata, cardsMetadata); + const hasMissingCards = cardIDs.some((cardID) => !allBankCards?.[cardID]); + const selectedDates = cardIDs.map((cardID) => allBankCards?.[cardID]?.scrapeMinDate ?? ''); + const uniqueDates = new Set(selectedDates); + const areAllFromBeginning = selectedDates.every((date) => !date); + const sharedStartDate = uniqueDates.size === 1 ? selectedDates.at(0) : undefined; + const sharedDateOption = areAllFromBeginning ? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING : CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; + const initialDateOption = uniqueDates.size > 1 ? '' : sharedDateOption; + const initialStartDate = sharedStartDate ? format(parseISO(sharedStartDate), CONST.DATE.FNS_FORMAT_STRING) : ''; + + const goBackToCompanyCards = () => Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)); + + const submit = (newStartDate: string) => { + bulkUpdateCardTransactionStartDate( + domainOrWorkspaceAccountID, + bank, + cardIDs.map((cardID) => ({ + cardID, + oldStartDate: allBankCards?.[cardID]?.scrapeMinDate, + })), + newStartDate, + ); + goBackToCompanyCards(); + }; + + return ( + + {isLoading ? ( + + ) : ( + + )} + + ); +} + +export default WorkspaceCompanyCardsBulkEditTransactionStartDatePage; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index 56bcae33b2e5..4cbc737555c6 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -8,6 +8,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -15,6 +16,7 @@ import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {getDomainOrWorkspaceAccountID} from '@libs/CardUtils'; +import Growl from '@libs/Growl'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; @@ -23,9 +25,10 @@ import {canMemberWrite, getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; -import {openPolicyCompanyCardsFeed, openPolicyCompanyCardsPage} from '@userActions/CompanyCards'; +import {clearBulkUpdateTransactionStartDateSuccess, clearBulkUpdateTransactionStartDateUpdate, openPolicyCompanyCardsFeed, openPolicyCompanyCardsPage} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -57,6 +60,33 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { } = companyCards; const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeed); + const [companyCardsLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE}${domainOrWorkspaceAccountID}`); + + const bulkTransactionStartDateUpdateID = companyCardsLoadingState?.bulkTransactionStartDateUpdateID; + const lastHandledBulkTransactionStartDateUpdateID = useRef(undefined); + + useEffect(() => { + if (!bulkTransactionStartDateUpdateID || lastHandledBulkTransactionStartDateUpdateID.current === bulkTransactionStartDateUpdateID) { + return; + } + + lastHandledBulkTransactionStartDateUpdateID.current = bulkTransactionStartDateUpdateID; + clearBulkUpdateTransactionStartDateUpdate(domainOrWorkspaceAccountID); + companyCardsTableRef.current?.clearSelection(); + }, [bulkTransactionStartDateUpdateID, domainOrWorkspaceAccountID]); + + const bulkTransactionStartDateUpdateSuccessID = companyCardsLoadingState?.bulkTransactionStartDateUpdateSuccessID; + const lastHandledBulkTransactionStartDateUpdateSuccessID = useRef(undefined); + + useEffect(() => { + if (!bulkTransactionStartDateUpdateSuccessID || lastHandledBulkTransactionStartDateUpdateSuccessID.current === bulkTransactionStartDateUpdateSuccessID) { + return; + } + + lastHandledBulkTransactionStartDateUpdateSuccessID.current = bulkTransactionStartDateUpdateSuccessID; + clearBulkUpdateTransactionStartDateSuccess(domainOrWorkspaceAccountID); + Growl.success({bodyText: translate('workspace.companyCards.bulkStartDateUpdated'), position: CONST.GROWL.POSITION.BOTTOM_RIGHT}); + }, [bulkTransactionStartDateUpdateSuccessID, domainOrWorkspaceAccountID, translate]); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); diff --git a/src/types/onyx/CompanyCardsLoadingState.ts b/src/types/onyx/CompanyCardsLoadingState.ts index 6940dc20480f..a3c75f516210 100644 --- a/src/types/onyx/CompanyCardsLoadingState.ts +++ b/src/types/onyx/CompanyCardsLoadingState.ts @@ -18,6 +18,12 @@ type CompanyCardsLoadingState = { /** Whether workspace company card feeds have been fetched at least once in this session */ hasOnceLoadedPage?: boolean; + /** Identifies the latest successful bulk transaction start date update */ + bulkTransactionStartDateUpdateSuccessID?: string; + + /** Identifies the latest queued bulk transaction start date update */ + bulkTransactionStartDateUpdateID?: string; + /** Per-feed session loading state keyed by feed name */ feeds?: Partial>; }; diff --git a/tests/actions/CompanyCardsTransactionStartDateTest.ts b/tests/actions/CompanyCardsTransactionStartDateTest.ts new file mode 100644 index 000000000000..84df71d1be7b --- /dev/null +++ b/tests/actions/CompanyCardsTransactionStartDateTest.ts @@ -0,0 +1,72 @@ +import {bulkUpdateCardTransactionStartDate, updateCardTransactionStartDate} from '@libs/actions/CompanyCards'; +import {WRITE_COMMANDS} from '@libs/API/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +const DOMAIN_OR_WORKSPACE_ACCOUNT_ID = 1234567; +const CARD_ID = '123'; +const FEED = CONST.COMPANY_CARD.FEED_BANK_NAME.VISA; + +describe('actions/CompanyCards transaction start date updates', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('removes an optimistically added date when a single-card update fails', () => { + // Given a card that previously imported transactions from the beginning + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + + // When setting a custom start date + updateCardTransactionStartDate(DOMAIN_OR_WORKSPACE_ACCOUNT_ID, CARD_ID, '2026-10-13', FEED, undefined); + + // Then a failed request rolls the missing date back with null so Onyx removes the optimistic value + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.UPDATE_CARD_TRANSACTION_START_DATE, + expect.anything(), + expect.objectContaining({ + failureData: [ + expect.objectContaining({ + value: { + [CARD_ID]: expect.objectContaining({scrapeMinDate: null}), + }, + }), + ], + }), + ); + }); + + it('rolls back absent dates, signals the queued update immediately, and signals success only through success data for bulk updates', () => { + // Given cards that previously imported transactions from the beginning + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + const onyxMergeSpy = jest.spyOn(Onyx, 'merge'); + + // When setting a custom start date in bulk + bulkUpdateCardTransactionStartDate(DOMAIN_OR_WORKSPACE_ACCOUNT_ID, FEED, [{cardID: CARD_ID, oldStartDate: undefined}], '2026-10-13'); + + // Then the queued request signals selection clearing immediately + expect(onyxMergeSpy).toHaveBeenCalledWith(`${ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE}${DOMAIN_OR_WORKSPACE_ACCOUNT_ID}`, expect.anything()); + + // And failure data removes the optimistic date while success data signals the confirmed server response + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.BULK_UPDATE_CARD_TRANSACTION_START_DATE, + expect.anything(), + expect.objectContaining({ + failureData: [ + expect.objectContaining({ + value: { + [CARD_ID]: expect.objectContaining({scrapeMinDate: null}), + }, + }), + ], + successData: [ + expect.objectContaining({ + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE}${DOMAIN_OR_WORKSPACE_ACCOUNT_ID}`, + }), + ], + }), + ); + }); +}); diff --git a/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx b/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx index 17132407f942..c1b37d061fe5 100644 --- a/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx +++ b/tests/ui/WorkspaceCompanyCardsExportAccountColumnTest.tsx @@ -31,10 +31,13 @@ const FEED_NAME = `${CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE}#${DOMAIN_OR_WORKSP const BANK_NAME = CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE as CompanyCardFeedWithNumber; const ACCOUNT_ID = 1; const QBO_EXPORT_ACCOUNT_NVP = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_QUICKBOOKS_ONLINE_EXPORT_ACCOUNT; +const ROOT_ERROR_KEY = 'rootError'; +const START_DATE_ERROR_KEY = 'startDateError'; let capturedColumns: Array> = []; let capturedData: WorkspaceCompanyCardTableItemData[] = []; let capturedCompareItems: CompareItemsCallback | undefined; +let mockCardFeedErrors: Record = {}; jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyIllustrations: () => ({}), @@ -43,7 +46,7 @@ jest.mock('@hooks/useLazyAsset', () => ({ jest.mock('@hooks/useCardFeedErrors', () => ({ __esModule: true, - default: () => ({cardFeedErrors: {}}), + default: () => ({cardFeedErrors: mockCardFeedErrors}), })); jest.mock('@hooks/useNetwork', () => ({ @@ -206,6 +209,7 @@ async function setQBOPolicy(nvpExportAccount?: string) { describe('WorkspaceCompanyCardsTable Export account column', () => { beforeEach(async () => { + mockCardFeedErrors = {}; await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -314,3 +318,87 @@ describe('WorkspaceCompanyCardsTable Export account column', () => { expect(screen.getByTestId('WorkspaceCompanyCardsTable')).toBeTruthy(); }); }); + +describe('WorkspaceCompanyCardsTable row feedback', () => { + beforeEach(async () => { + mockCardFeedErrors = {}; + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('combines root and transaction start date errors on the same row', async () => { + // Given an assigned card whose unassignment and start-date updates both failed + const card = buildAssignedCard({ + errors: {[ROOT_ERROR_KEY]: 'Unassignment failed'}, + errorFields: {scrapeMinDate: {[START_DATE_ERROR_KEY]: 'Start date update failed'}}, + }); + + // When the company cards table builds its row data + renderTable(buildCompanyCards(card)); + await waitForBatchedUpdates(); + + // Then both failures are forwarded together instead of competing for one slot + expect(capturedData.at(0)?.errors).toEqual({[ROOT_ERROR_KEY]: 'Unassignment failed', [START_DATE_ERROR_KEY]: 'Start date update failed'}); + }); + + it('forwards the transaction start date pending state to the row', async () => { + // Given an assigned card with an offline transaction start date update + const card = buildAssignedCard({pendingFields: {scrapeMinDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}); + + // When the company cards table builds its row data + renderTable(buildCompanyCards(card)); + await waitForBatchedUpdates(); + + // Then OfflineWithFeedback receives the field-level pending state + expect(capturedData.at(0)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + }); + + it('prioritizes card deletion over a pending transaction start date update', async () => { + // Given a card that is being unassigned while its transaction start date update is still pending + const card = buildAssignedCard({ + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + pendingFields: {scrapeMinDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }); + + // When the company cards table builds its row data + renderTable(buildCompanyCards(card)); + await waitForBatchedUpdates(); + + // Then the root deletion remains authoritative and the row is filtered from the online table + expect(capturedData).toHaveLength(0); + }); + + it('shows a failed unassignment after its optimistic deletion is rolled back', async () => { + // Given an unassignment failure while the transaction start date update is still pending + const card = buildAssignedCard({ + pendingAction: null, + pendingFields: {scrapeMinDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errors: {[ROOT_ERROR_KEY]: 'Unassignment failed'}, + }); + + // When the company cards table builds its row data + renderTable(buildCompanyCards(card)); + await waitForBatchedUpdates(); + + // Then the row returns with its RBR while retaining the field-level pending feedback + expect(capturedData).toHaveLength(1); + expect(capturedData.at(0)?.errors).toEqual({[ROOT_ERROR_KEY]: 'Unassignment failed'}); + expect(capturedData.at(0)?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + }); + + it('keeps a failed transaction start date update visible while the feed-level connection error is displayed', async () => { + // Given a broken feed and a card with both root and start-date errors + mockCardFeedErrors = {[FEED_NAME]: {shouldPromptBrokenConnection: true}}; + const card = buildAssignedCard({ + errors: {[ROOT_ERROR_KEY]: 'Unassignment failed'}, + errorFields: {scrapeMinDate: {[START_DATE_ERROR_KEY]: 'Start date update failed'}}, + }); + + // When the company cards table builds its row data + renderTable(buildCompanyCards(card)); + await waitForBatchedUpdates(); + + // Then the feed-level prompt owns the root error while the independent field failure remains actionable on the row + expect(capturedData.at(0)?.errors).toEqual({[START_DATE_ERROR_KEY]: 'Start date update failed'}); + }); +}); diff --git a/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx b/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx index 1df42c526da5..db7f3588b927 100644 --- a/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx +++ b/tests/ui/WorkspaceCompanyCardsTableControlsTest.tsx @@ -8,10 +8,15 @@ import localFileDownload from '@libs/localFileDownload'; import React from 'react'; +type MockDropdownOption = { + value: string; + onSelected: () => void; +}; + type MockButtonProps = { customText: string; isDisabled?: boolean; - options?: Array<{value: string; onSelected?: () => void | Promise}>; + options: MockDropdownOption[]; }; const mockButtonProps: {current?: MockButtonProps} = {}; @@ -87,18 +92,22 @@ jest.mock('@libs/actions/CompanyCards', () => ({unassignWorkspaceCompanyCard: je jest.mock('@libs/CardNavigationUtils', () => jest.fn()); jest.mock('@libs/CardUtils', () => ({formatMaskedCardName: jest.fn()})); jest.mock('@libs/localFileDownload', () => jest.fn()); +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn()})); function renderControls(isSelectionModeEnabled: boolean) { - return render( + const clearCardSelection = jest.fn(); + const renderResult = render( , ); + return {clearCardSelection, ...renderResult}; } describe('WorkspaceCompanyCardsTableControls narrow-layout selection mode', () => { @@ -140,7 +149,7 @@ describe('WorkspaceCompanyCardsTableControls narrow-layout selection mode', () = renderControls(true); // When the CSV export action is selected - await mockButtonProps.current?.options?.find((option) => option.value === 'exportCSV')?.onSelected?.(); + mockButtonProps.current?.options?.find((option) => option.value === 'exportCSV')?.onSelected?.(); // Then the existing export still uses one safe text cell for the card name expect(localFileDownload).toHaveBeenCalledWith('CompanyCards_policy123.csv', expect.stringContaining('\r\nalex@example.com,"\'=SUM(1,2)",1234,'), expect.any(Function));