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));