Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9a0d2c2
Init bulk update start date
hungvu193 Sep 24, 2026
df1eeed
update translations
hungvu193 Sep 24, 2026
a18dd33
Use Bulk API instead
hungvu193 Sep 24, 2026
4614fb2
Add bottom right position to Growl
hungvu193 Sep 24, 2026
a62e883
fix failing type check
hungvu193 Sep 24, 2026
b433dc9
Merge remote-tracking branch 'origin' into feat-bulk-transaction-star…
hungvu193 Oct 12, 2026
6893edc
Fix company card start date navigation
hungvu193 Oct 12, 2026
5e746a5
Fix single-card bulk start date updates
hungvu193 Oct 12, 2026
ed67c41
Use single-card editor for one-card bulk routes
hungvu193 Oct 12, 2026
506a575
Decode company card feed for start date edits
hungvu193 Oct 12, 2026
54db234
Handle failed updating case
hungvu193 Oct 12, 2026
3d67eea
update the start date
hungvu193 Oct 12, 2026
504a5d9
Fix mSafari statusbar issue with Growl
hungvu193 Oct 12, 2026
053dcf2
reuse existing logic instead of duplicating new one
hungvu193 Oct 12, 2026
059c367
Address bulk start date review feedback
hungvu193 Oct 13, 2026
b6dfcec
cleanup optimistic data, prevent clearing selection
hungvu193 Oct 14, 2026
c7f8a3b
share the date between cards
hungvu193 Oct 14, 2026
1a7f584
fix start date issue
hungvu193 Oct 14, 2026
6dfb2f3
fix initial start date option
hungvu193 Oct 14, 2026
83ae291
remove redundant test since we used the new flow
hungvu193 Oct 14, 2026
f3df942
linting
hungvu193 Oct 14, 2026
378c6e7
use set for better date handling
hungvu193 Oct 14, 2026
c82bec3
Fix default selected logic
hungvu193 Oct 15, 2026
05ca6c0
clear selection when updating sucessfully
hungvu193 Oct 15, 2026
f4635bb
add explanation for growl
hungvu193 Oct 15, 2026
c1a68bb
Merge remote-tracking branch 'origin' into feat-bulk-transaction-star…
hungvu193 Oct 16, 2026
bc90bfe
Fix reviews
hungvu193 Oct 16, 2026
ffe35fe
linting
hungvu193 Oct 16, 2026
3790d21
Fix company card bulk update feedback
hungvu193 Oct 17, 2026
8cb7282
merge main
hungvu193 Oct 19, 2026
5940247
fix conflicts
hungvu193 Oct 20, 2026
1ec3d2e
merge main
hungvu193 Oct 20, 2026
c3332b6
Merge main and resolve conflicts
hungvu193 Oct 21, 2026
800f81c
revert wrong action
hungvu193 Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4838,17 +4838,17 @@ 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.
DURATION_WITH_ACTION: 6000,
// 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,
Expand Down
7 changes: 7 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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};
Expand Down
4 changes: 2 additions & 2 deletions src/components/Avatar/layouts/SubscriptCardFeedAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ function GrowlNotificationContainer({children, progress, inactiveY, useBottomPos
const animatedStyles = useAnimatedStyle(() => ({transform: [{translateY: inactiveY * (1 - progress.get())}]}), [inactiveY]);

if (useBottomPosition) {
return <Animated.View style={[styles.growlNotificationContainerBottomRight, animatedStyles]}>{children}</Animated.View>;
return (
<Animated.View style={[styles.growlNotificationContainerBottomRight, shouldUseNarrowLayout && styles.pAbsolute, shouldUseNarrowLayout && styles.mwn, animatedStyles]}>
{children}
</Animated.View>
);
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Comment thread
hungvu193 marked this conversation as resolved.
Resolved

/** Identifies this growl instance; passed back through onDismissed so the parent can ignore stale dismissals. */
Expand Down Expand Up @@ -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);
Comment thread
hungvu193 marked this conversation as resolved.
Resolved
const inactiveY = useBottomPosition ? INACTIVE_OFFSET : INACTIVE_POSITION_Y;

const types: GrowlIconTypes = {
Expand Down
6 changes: 4 additions & 2 deletions src/components/GrowlNotification/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type GrowlContent = {
type: GrowlType;
duration: number;
action?: GrowlAction;

/** Position where the growl is anchored */
position?: GrowlPosition;
Comment thread
hungvu193 marked this conversation as resolved.
Resolved

/** Bumped on every show() call so identical-args re-shows still remount and re-trigger the growl. */
Expand All @@ -30,8 +32,8 @@ type GrowlNotificationProps = {
function GrowlNotification({ref}: GrowlNotificationProps) {
const [content, setContent] = useState<GrowlContent | null>(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<GrowlRef['show']> = ({bodyText, type, duration, action, position}) => {
setContent((prev) => ({bodyText, type, duration, action, position, nonce: (prev?.nonce ?? 0) + 1}));
};

useImperativeHandle(ref, () => ({show}), [show]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type {GrowlPosition} from '@libs/Growl';

const shouldUseExplicitBottomPosition: (position?: GrowlPosition) => boolean = () => false;

export default shouldUseExplicitBottomPosition;
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion src/components/SupportTicketResolvedFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,24 @@ 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';
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;
Expand All @@ -47,6 +50,7 @@ function WorkspaceCompanyCardsTableControls({
policyID,
domainOrWorkspaceAccountID,
bankName,
feedName,
canWriteCompanyCards,
clearCardSelection,
isSelectionModeEnabled,
Expand All @@ -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<WorkspaceCompanyCardTableItemData>();
const {shouldUseNarrowLayout} = useResponsiveLayout();

Expand Down Expand Up @@ -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<DropdownOption<WorkspaceCompanyCardBulkActionType>> => {
const options: Array<DropdownOption<WorkspaceCompanyCardBulkActionType>> = [];

Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ 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';

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';
Expand Down Expand Up @@ -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}` : '';
Expand Down Expand Up @@ -147,7 +146,7 @@ function WorkspaceCompanyCardTableRow({
{isAssigned ? (
<AccountAvatarWithCardFeed
accountID={cardholder?.accountID ?? CONST.DEFAULT_NUMBER_ID}
cardFeed={assignedCard?.bank as CompanyCardFeed}
cardFeed={getCompanyCardFeed(assignedCard?.bank)}
cardFeedIconSize={subscriptCardFeedIconSize}
borderColor={hovered ? theme.hoverComponentBG : theme.highlightBG}
containerStyle={styles.mr0}
Expand Down
25 changes: 21 additions & 4 deletions src/components/Tables/WorkspaceCompanyCardsTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';

import {resetFailedWorkspaceCompanyCardUnassignment} from '@libs/actions/CompanyCards';
import {clearCompanyCardErrorField, resetFailedWorkspaceCompanyCardUnassignment} from '@libs/actions/CompanyCards';
import {renameCompanyCardInline} from '@libs/actions/Policy/InlineEdit';
import {formatMaskedCardName, getCompanyCardCustomName, getDefaultCardName} from '@libs/CardUtils';
import {getLatestErrorField} from '@libs/ErrorUtils';
import {getConnectedIntegration} from '@libs/PolicyUtils';
import showInlineEditError from '@libs/showInlineEditError';
import tokenizedSearch from '@libs/tokenizedSearch';
Expand All @@ -36,6 +37,7 @@ import variables from '@styles/variables';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

import type {ListRenderItemInfo} from '@shopify/flash-list';
Expand Down Expand Up @@ -287,6 +289,13 @@ function WorkspaceCompanyCardsTable({
const storedCardName = getCompanyCardCustomName(cardID, sharedCardCustomNames, customCardNames);
const customCardName = storedCardName ?? getDefaultCardName(cardholder?.displayName ?? '');
const canEditName = canWriteCompanyCards && !!bankName && isAssigned && cardID !== undefined && !isCardDeleted && !isSelectionModeActive;
const transactionStartDateErrors = getLatestErrorField(assignedCard ?? {}, 'scrapeMinDate');
const hasTransactionStartDateErrors = !isEmptyObject(transactionStartDateErrors);
// The feed-level prompt owns connection errors, but a failed start-date edit is a separate user action
// and must remain visible and dismissible on the affected row.
const cardErrors = isFeedConnectionBroken || assignedCard?.pendingFields?.lastScrape ? undefined : assignedCard?.errors;
const hasVisibleCardErrors = !isEmptyObject(cardErrors ?? {});
const rowErrors = hasTransactionStartDateErrors ? {...cardErrors, ...transactionStartDateErrors} : cardErrors;

return {
cardName,
Expand All @@ -300,16 +309,23 @@ function WorkspaceCompanyCardsTable({
cardholder,
// Unassigned cards have no details page and so no Accounting section to match, hence no title.
exportAccountTitle: shouldShowExportAccountColumn && assignedCard ? getCardExportAccountTitle(cardExportSettings, assignedCard) : undefined,
errors: isFeedConnectionBroken || assignedCard?.pendingFields?.lastScrape ? undefined : assignedCard?.errors,
pendingAction: assignedCard?.pendingAction,
errors: rowErrors,
pendingAction: assignedCard?.pendingAction ?? assignedCard?.pendingFields?.scrapeMinDate,
canEditName,
onRenameName: (newName: string) => {
if (!bankName || cardID === undefined) {
return;
}
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);
Expand Down Expand Up @@ -483,6 +499,7 @@ function WorkspaceCompanyCardsTable({
policyID={policyID}
domainOrWorkspaceAccountID={domainOrWorkspaceAccountID}
bankName={bankName}
feedName={feedName}
canWriteCompanyCards={canWriteCompanyCards}
clearCardSelection={clearCardSelection}
isSelectionModeEnabled={isSelectionModeEnabled}
Expand Down
Loading
Loading