diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c6f0692ea191..8c82c45295ee 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4066,6 +4066,9 @@ const CONST = { '2015', ], }, + BULK_ACTIONS: { + EXPORT_CSV: 'exportCSV', + }, }, PERSONAL_CARDS: { FEED_KEY_SEPARATOR: '#', @@ -9252,6 +9255,7 @@ const CONST = { SELECT_PARTICIPANT: 'NewChat-SelectParticipant', }, WORKSPACE_EXPENSIFY_CARD: { + BULK_ACTIONS_DROPDOWN: 'WorkspaceExpensifyCard-BulkActionsDropdown', CARD_LIST_ROW: 'WorkspaceExpensifyCard-CardListRow', }, WORKSPACE: { diff --git a/src/hooks/useCurrencyForExpensifyCard.ts b/src/hooks/useCurrencyForExpensifyCard.ts index 9314c4b07564..60a875b52128 100644 --- a/src/hooks/useCurrencyForExpensifyCard.ts +++ b/src/hooks/useCurrencyForExpensifyCard.ts @@ -5,6 +5,5 @@ import usePolicy from './usePolicy'; export default function useCurrencyForExpensifyCard({policyID}: {policyID?: string}) { const policy = usePolicy(policyID); const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID); - - return isUkEuCurrencySupported ? policy?.outputCurrency : CONST.CURRENCY.USD; + return isUkEuCurrencySupported ? (policy?.outputCurrency ?? CONST.CURRENCY.USD) : CONST.CURRENCY.USD; } diff --git a/src/languages/de.ts b/src/languages/de.ts index 5869ba86cb2b..1fdadb0e8efa 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5403,6 +5403,10 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU oneMoreStepDescription: 'Es sieht so aus, als müssten wir Ihr Bankkonto manuell verifizieren. Bitte gehen Sie zu Concierge, wo bereits Anweisungen auf Sie warten.', gotIt: 'Verstanden', goToConcierge: 'Zu Concierge gehen', + exportAsCSV: 'Als CSV exportieren', + csvColumnType: 'Typ', + csvColumnLimitType: 'Limittyp', + csvColumnLimit: 'Limit', }, categories: { deleteCategories: 'Kategorien löschen', diff --git a/src/languages/en.ts b/src/languages/en.ts index f17682b4f6e7..2bdba9784711 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5335,6 +5335,10 @@ const translations = { euUkDisclaimer: 'Cards provided to EEA residents are issued by Transact Payments Malta Limited and cards provided to UK residents are issued by Transact Payments Limited pursuant to license by Visa Europe Limited. Transact Payments Malta Limited is duly authorized and regulated by the Malta Financial Services Authority as a Financial Institution under the Financial Institution Act 1994. Registration number C 91879. Transact Payments Limited is authorized and regulated by the Gibraltar Financial Service Commission.', issueCard: 'Issue card', + exportAsCSV: 'Export as CSV', + csvColumnType: 'Type', + csvColumnLimitType: 'Limit type', + csvColumnLimit: 'Limit', findCard: 'Find card', newCard: 'New card', name: 'Name', diff --git a/src/languages/es.ts b/src/languages/es.ts index c7783a046753..fa1e4a6f9f56 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5191,6 +5191,10 @@ ${amount} para ${merchant} - ${date}`, euUkDisclaimer: 'Las tarjetas proporcionadas a residentes del EEE son emitidas por Transact Payments Malta Limited, y las proporcionadas a residentes del Reino Unido son emitidas por Transact Payments Limited con licencia de Visa Europe Limited. Transact Payments Malta Limited está debidamente autorizada y regulada por la Autoridad de Servicios Financieros de Malta como Institución Financiera, de conformidad con la Ley de Instituciones Financieras de 1994. Número de registro: C 91879. Transact Payments Limited está autorizada y regulada por la Comisión de Servicios Financieros de Gibraltar.', issueCard: 'Emitir tarjeta', + exportAsCSV: 'Exportar como CSV', + csvColumnType: 'Tipo', + csvColumnLimitType: 'Tipo de límite', + csvColumnLimit: 'Límite', findCard: 'Encontrar tarjeta', newCard: 'Nueva tarjeta', name: 'Nombre', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d25a5aa6d2bd..8d0786b7ca67 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5412,6 +5412,10 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. oneMoreStepDescription: 'Il semble que nous devions vérifier votre compte bancaire manuellement. Rendez-vous dans Concierge, où vos instructions vous attendent.', gotIt: 'Compris', goToConcierge: 'Aller à Concierge', + exportAsCSV: 'Exporter en CSV', + csvColumnType: 'Type', + csvColumnLimitType: 'Type de limite', + csvColumnLimit: 'Limite', }, categories: { deleteCategories: 'Supprimer des catégories', diff --git a/src/languages/it.ts b/src/languages/it.ts index abccbb17424e..cfd9385c1180 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5383,6 +5383,10 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. oneMoreStepDescription: 'Sembra che dobbiamo verificare manualmente il tuo conto bancario. Vai su Concierge, dove ti aspettano le istruzioni.', gotIt: 'Ho capito', goToConcierge: 'Vai a Concierge', + exportAsCSV: 'Esporta come CSV', + csvColumnType: 'Tipo', + csvColumnLimitType: 'Tipo di limite', + csvColumnLimit: 'Limite', }, categories: { deleteCategories: 'Elimina categorie', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 51ae05979330..8e3c0eecfbf1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5332,6 +5332,10 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO oneMoreStepDescription: '銀行口座を手動で確認する必要があるようです。指示が表示されていますので、Concierge 画面に進んでください。', gotIt: '了解しました', goToConcierge: 'Concierge へ移動', + exportAsCSV: 'CSVでエクスポート', + csvColumnType: 'タイプ', + csvColumnLimitType: '限度タイプ', + csvColumnLimit: '限度額', }, categories: { deleteCategories: 'カテゴリを削除', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b43fcd96d7cc..65809a2c8637 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5375,6 +5375,10 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ oneMoreStepDescription: 'Het lijkt erop dat we je bankrekening handmatig moeten verifiëren. Ga naar Concierge, waar de instructies voor je klaarstaan.', gotIt: 'Begrepen', goToConcierge: 'Ga naar Concierge', + exportAsCSV: 'Exporteren als CSV', + csvColumnType: 'Type', + csvColumnLimitType: 'Limiettype', + csvColumnLimit: 'Limiet', }, categories: { deleteCategories: 'Categorieën verwijderen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 07bbc38d5499..49c613d7fd5a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5362,6 +5362,10 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy oneMoreStepDescription: 'Wygląda na to, że musimy ręcznie zweryfikować Twoje konto bankowe. Przejdź do Concierge, gdzie czekają na Ciebie dalsze instrukcje.', gotIt: 'Rozumiem', goToConcierge: 'Przejdź do Concierge', + exportAsCSV: 'Eksportuj jako CSV', + csvColumnType: 'Typ', + csvColumnLimitType: 'Typ limitu', + csvColumnLimit: 'Limit', }, categories: { deleteCategories: 'Usuń kategorie', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f5fef227eac1..a77df64a7d1b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5366,6 +5366,10 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS oneMoreStepDescription: 'Parece que precisamos verificar sua conta bancária manualmente. Vá até o Concierge, onde as instruções estão esperando por você.', gotIt: 'Entendi', goToConcierge: 'Ir para o Concierge', + exportAsCSV: 'Exportar como CSV', + csvColumnType: 'Tipo', + csvColumnLimitType: 'Tipo de limite', + csvColumnLimit: 'Limite', }, categories: { deleteCategories: 'Excluir categorias', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index de77d70b93f9..fa53f5ff5b1b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5245,6 +5245,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM oneMoreStepDescription: '看起来我们需要手动验证您的银行账户。请前往 Concierge 查看为您准备的操作说明。', gotIt: '明白了', goToConcierge: '前往 Concierge', + exportAsCSV: '导出为 CSV', + csvColumnType: '类型', + csvColumnLimitType: '限额类型', + csvColumnLimit: '限额', }, categories: { deleteCategories: '删除类别', diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index bbb6df2d0f69..188941cc2451 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -1,6 +1,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import type {PartialDeep, ValueOf} from 'type-fest'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import * as API from '@libs/API'; import type { ActivatePhysicalExpensifyCardParams, @@ -24,15 +25,19 @@ import type { } from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import type {CardProgramKey} from '@libs/CardUtils'; +import {getTranslationKeyForLimitType} from '@libs/CardUtils'; +import {convertToShortDisplayString} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import localFileDownload from '@libs/localFileDownload'; import Log from '@libs/Log'; +import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {isReportOpenOrUnsubmitted} from '@libs/ReportUtils'; import {buildSpendRuleAST} from '@libs/SpendRulesUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SpendRuleForm} from '@src/types/form'; -import type {Card, CompanyCardFeedWithDomainID, Report, Transaction} from '@src/types/onyx'; +import type {Card, CompanyCardFeedWithDomainID, PersonalDetailsList, Report, Transaction} from '@src/types/onyx'; import type {CardLimitType, ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card'; import type {ExpensifyCardRule} from '@src/types/onyx/ExpensifyCardSettings'; import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; @@ -1753,6 +1758,77 @@ function resolveFraudAlert(cardID: number | undefined, isFraud: boolean, reportI API.write(WRITE_COMMANDS.RESOLVE_FRAUD_ALERT, parameters, {optimisticData, successData, failureData}); } +function escapeCsvField(value: string): string { + if (value.includes('"') || value.includes(',') || value.includes('\n') || value.includes('\r')) { + return `"${value.replaceAll('"', '""')}"`; + } + return value; +} + +function getOwnerEmailForCard(card: Card, personalDetailsList: PersonalDetailsList | undefined): string { + const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; + return personalDetailsList?.[String(accountID)]?.login ?? ''; +} + +function getCardholderNameForCSV(card: Card, personalDetailsList: PersonalDetailsList | undefined): string { + const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; + const details = personalDetailsList?.[String(accountID)]; + if (!details?.displayName?.trim()) { + return ''; + } + return getDisplayNameOrDefault(details, '', false, false); +} + +type ExportExpensifyCardListToCSVParams = { + /** Workspace policy ID (used in the download filename) */ + policyID: string; + + /** Cards to include in the export (typically the current selection) */ + cards: Card[]; + + /** Personal details used to resolve cardholder email */ + personalDetailsList: PersonalDetailsList | undefined; + + /** Settlement / card program currency for limit amounts */ + settlementCurrency: string; + + translate: LocalizedTranslate; +}; + +function exportExpensifyCardListToCSV({policyID, cards, personalDetailsList, settlementCurrency, translate}: ExportExpensifyCardListToCSVParams) { + if (cards.length === 0) { + return; + } + + const header = [ + translate('common.email'), + translate('workspace.expensifyCard.name'), + translate('workspace.expensifyCard.lastFour'), + translate('workspace.expensifyCard.csvColumnType'), + translate('workspace.expensifyCard.csvColumnLimitType'), + translate('workspace.expensifyCard.csvColumnLimit'), + ] + .map(escapeCsvField) + .join(','); + + const rows = cards.map((card) => { + const owner = getOwnerEmailForCard(card, personalDetailsList); + const ownerNameColumn = getCardholderNameForCSV(card, personalDetailsList); + const lastFourColumn = card.lastFourPAN ?? ''; + const typeColumn = card.nameValuePairs?.isVirtual ? translate('workspace.expensifyCard.virtual') : translate('workspace.expensifyCard.physical'); + const limitTypeColumn = translate(getTranslationKeyForLimitType(card.nameValuePairs?.limitType)); + const limitAmount = card.nameValuePairs?.unapprovedExpenseLimit ?? 0; + const limitColumn = convertToShortDisplayString(limitAmount, settlementCurrency); + + return [owner, ownerNameColumn, lastFourColumn, typeColumn, limitTypeColumn, limitColumn].map(escapeCsvField).join(','); + }); + + const csvContent = [header, ...rows].join('\r\n'); + const safePolicySegment = policyID.replaceAll(/[^\dA-Za-z-_]/g, '') || 'workspace'; + const fileName = `ExpensifyCards_${safePolicySegment}.csv`; + localFileDownload(fileName, csvContent, translate); +} + export { requestReplacementExpensifyCard, activatePhysicalExpensifyCard, @@ -1792,7 +1868,8 @@ export { clearIssueNewCardFormData, setDraftInviteAccountID, resolveFraudAlert, + exportExpensifyCardListToCSV, deleteExpensifyCardRule, setExpensifyCardRule, }; -export type {ReplacementReason}; +export type {ExportExpensifyCardListToCSVParams, ReplacementReason}; diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index eca440a146c1..6938b472e3e4 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -953,6 +953,7 @@ function ReportDetailsPage({policy, report, route, reportMetadata, reportLoading parentReport, isReportArchived, currentUserPersonalDetails.accountID, + currentUserPersonalDetails.email, hasOutstandingChildTask, parentReportAction, ancestors, diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx index c2cf52740fe1..5f539d0283bc 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListHeader.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; import FormHelpMessage from '@components/FormHelpMessage'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -11,9 +12,16 @@ import type {ExpensifyCardSettings} from '@src/types/onyx'; type WorkspaceCardListHeaderProps = { /** Card settings */ cardSettings: ExpensifyCardSettings | undefined; + + /** When set, shows a select-all control aligned with card row checkboxes */ + bulkSelection?: { + onSelectAll: () => void; + isSelectAllChecked: boolean; + isSelectAllIndeterminate: boolean; + }; }; -function WorkspaceCardListHeader({cardSettings}: WorkspaceCardListHeaderProps) { +function WorkspaceCardListHeader({cardSettings, bulkSelection}: WorkspaceCardListHeaderProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isMediumScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -34,6 +42,16 @@ function WorkspaceCardListHeader({cardSettings}: WorkspaceCardListHeaderProps) { )} + {!!bulkSelection && ( + + + + )} void; + }; }; -function WorkspaceCardListRow({limit, cardholder, lastFourPAN, name, frozenByDisplayName, frozenByAccountID, frozenDate, currency, isVirtual, isHovered, limitType}: WorkspacesListRowProps) { +function WorkspaceCardListRow({ + limit, + cardholder, + lastFourPAN, + name, + frozenByDisplayName, + frozenByAccountID, + frozenDate, + currency, + isVirtual, + isHovered, + limitType, + bulkSelection, +}: WorkspacesListRowProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'FallbackAvatar', 'FreezeCard']); const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -78,6 +98,15 @@ function WorkspaceCardListRow({limit, cardholder, lastFourPAN, name, frozenByDis return ( + {!!bulkSelection && ( + + + + )} (null); const policy = usePolicy(policyID); const defaultFundID = useDefaultFundID(policyID); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); - const card = cardsList?.[cardID]; const areApprovalsConfigured = getApprovalWorkflow(policy) !== CONST.POLICY.APPROVAL_MODE.OPTIONAL; const defaultLimitType = getDefaultExpensifyCardLimitType(policy); @@ -66,17 +63,12 @@ function WorkspaceEditCardLimitTypePage({route}: WorkspaceEditCardLimitTypePageP const [typeSelected, setTypeSelected] = useState(initialLimitType); const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); const [expirationToggle, setExpirationToggle] = useState(!!card?.nameValuePairs?.validFrom); - const currency = useCurrencyForExpensifyCard({policyID}); const isWorkspaceRhp = route.name === SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT_TYPE; - const personalDetails = usePersonalDetails(); - const assigneePersonalDetails = personalDetails?.[card?.accountID ?? CONST.DEFAULT_NUMBER_ID]; const assigneeTimeZone = assigneePersonalDetails?.timezone?.selected; - const minDate = assigneeTimeZone ? toZonedTime(new Date(), assigneeTimeZone) : new Date(); - const validFrom = card?.nameValuePairs?.validFrom; const validFromDefaultValue = validFrom ? DateUtils.formatUTCDateTimeToDateInTimezone(validFrom, assigneeTimeZone) : format(minDate, CONST.DATE.FNS_FORMAT_STRING); diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 54fc050fff42..c62715481a29 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -4,6 +4,7 @@ import {FlatList, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import CardFeedIcon from '@components/CardFeedIcon'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import FeedSelector from '@components/FeedSelector'; @@ -29,7 +30,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {clearIssueNewCardFormData, setIssueNewCardStepAndData} from '@libs/actions/Card'; +import {clearIssueNewCardFormData, exportExpensifyCardListToCSV, setIssueNewCardStepAndData} from '@libs/actions/Card'; import {clearDeletePaymentMethodError} from '@libs/actions/PaymentMethods'; import {filterCardsByPersonalDetails, getCardsByCardholderName, getCardSettings, sortCardsByCardholderName} from '@libs/CardUtils'; import {getExpensifyCardFeedDescription} from '@libs/ExpensifyCardFeedSelectorUtils'; @@ -61,12 +62,11 @@ type WorkspaceExpensifyCardListPageProps = { }; function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExpensifyCardListPageProps) { - const icons = useMemoizedLazyExpensifyIcons(['Gear', 'Plus']); + const icons = useMemoizedLazyExpensifyIcons(['Export', 'Gear', 'Plus']); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const {translate, localeCompare} = useLocalize(); const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['HandCard', 'ExpensifyCardImage']); - const policyID = route.params.policyID; const policy = usePolicy(policyID); const defaultFundID = useDefaultFundID(policyID); @@ -76,22 +76,17 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const settings = getCardSettings(cardSettings); const {allFeeds: allAdminExpensifyCardFeeds} = useExpensifyCardFeedsForFeedSelector(policyID); - const shouldShowSelector = allAdminExpensifyCardFeeds.length >= 1; - const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID); - const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout; - const isBankAccountVerified = !cardOnWaitlist; const {windowHeight} = useWindowDimensions(); const headerHeight = useEmptyViewHeaderHeight(shouldUseNarrowLayout, isBankAccountVerified); const [footerHeight, setFooterHeight] = useState(0); - const cardFeedIcon = ( { const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); return getCardsByCardholderName(cardsList, policyMembersAccountIDs); }, [cardsList, policy?.employeeList]); const isCardListEmpty = allCards.length === 0; - const filterCard = useCallback((card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails), [personalDetails]); const sortCards = useCallback((cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare), [personalDetails, localeCompare]); const [inputValue, setInputValue, filteredSortedCards] = useSearchResults(allCards, filterCard, sortCards); + const [selectedCardIDs, setSelectedCardIDs] = useState([]); + const selectableCardIDs = filteredSortedCards.map((card) => card.cardID); + + const prunedSelectedCardIDs = selectedCardIDs.filter((id) => selectableCardIDs.includes(id)); + if (prunedSelectedCardIDs.length !== selectedCardIDs.length) { + setSelectedCardIDs(prunedSelectedCardIDs); + } + const toggleCardSelection = (cardID: number) => { + setSelectedCardIDs((prev) => (prev.includes(cardID) ? prev.filter((id) => id !== cardID) : [...prev, cardID])); + }; + const toggleSelectAll = () => { + if (selectableCardIDs.length === 0) { + return; + } + setSelectedCardIDs((prev) => { + if (prev.length > 0) { + return []; + } + return [...selectableCardIDs]; + }); + }; + const isSelectAllChecked = selectedCardIDs.length > 0 && selectedCardIDs.length === selectableCardIDs.length; + const isSelectAllIndeterminate = selectedCardIDs.length > 0 && selectedCardIDs.length < selectableCardIDs.length; + const bulkExportOptions: Array> = [ + { + icon: icons.Export, + text: translate('workspace.expensifyCard.exportAsCSV'), + value: CONST.EXPENSIFY_CARD.BULK_ACTIONS.EXPORT_CSV, + onSelected: () => { + const selectedCards = filteredSortedCards.filter((card) => selectedCardIDs.includes(card.cardID)); + exportExpensifyCardListToCSV({ + policyID, + cards: selectedCards, + personalDetailsList: personalDetails, + settlementCurrency, + translate, + }); + }, + }, + ]; const handleIssueCardPress = () => { clearIssueNewCardFormData(); @@ -126,89 +159,113 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp setIssueNewCardStepAndData({policyID, isChangeAssigneeDisabled: false}); Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.getRoute(policyID, activeRoute)); }; + const secondaryActions = [ + { + icon: icons.Gear, + text: translate('common.settings'), + onSelected: () => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS.getRoute(policyID)), + value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, + }, + ]; + const getHeaderButtons = () => { + const headerButtonsRowStyle = [styles.flexRow, styles.gap2, !shouldShowSelector && shouldUseNarrowLayout && styles.mb3, shouldShowSelector && shouldChangeLayout && styles.mt3]; - const secondaryActions = useMemo( - () => [ - { - icon: icons.Gear, - text: translate('common.settings'), - onSelected: () => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_SETTINGS.getRoute(policyID)), - value: CONST.POLICY.SECONDARY_ACTIONS.SETTINGS, - }, - ], - [icons.Gear, policyID, translate], - ); + if (selectedCardIDs.length > 0) { + return ( + + + success + onPress={() => {}} + customText={translate('workspace.common.selected', {count: selectedCardIDs.length})} + options={bulkExportOptions} + isSplitButton={false} + shouldAlwaysShowDropdownMenu + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE_EXPENSIFY_CARD.BULK_ACTIONS_DROPDOWN} + wrapperStyle={[styles.flexGrow1, shouldChangeLayout && styles.flexShrink1]} + /> + + ); + } - const getHeaderButtons = () => ( - - {!isCardListEmpty && ( -