diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d7da1da78f50..766126a81435 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -20,6 +20,12 @@ import type {ConnectionName, SageIntacctMappingName} from './types/onyx/Policy'; import type {CustomFieldType} from './types/onyx/PolicyEmployee'; import type AssertTypesNotEqual from './types/utils/AssertTypesNotEqual'; +type WorkspaceCompanyCardsAssignCardParams = { + policyID: string; + feed: string; + cardID?: string; +}; + // This is a file containing constants for all the routes we want to be able to go to /** @@ -2171,10 +2177,14 @@ const ROUTES = { getRoute: (policyID: string) => `workspaces/${policyID}/company-cards/select-feed` as const, }, WORKSPACE_COMPANY_CARDS_ASSIGN_CARD: { - route: 'workspaces/:policyID/company-cards/:feed/assign-card', + route: 'workspaces/:policyID/company-cards/:feed/assign-card/:cardID', - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, feed: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/company-cards/${encodeURIComponent(feed)}/assign-card`, backTo), + getRoute: (params: WorkspaceCompanyCardsAssignCardParams, backTo?: string) => + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + getUrlWithBackToParam( + `workspaces/${params.policyID}/company-cards/${encodeURIComponent(params.feed)}/assign-card${params.cardID ? `/${encodeURIComponent(params.cardID)}` : ''}`, + backTo, + ), }, WORKSPACE_COMPANY_CARD_DETAILS: { route: 'workspaces/:policyID/company-cards/:bank/:cardID', diff --git a/src/hooks/useAssignCard.ts b/src/hooks/useAssignCard.ts index 91b3a147a976..cd70b6231b41 100644 --- a/src/hooks/useAssignCard.ts +++ b/src/hooks/useAssignCard.ts @@ -74,7 +74,11 @@ function useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}: UseA const isAssigningCardDisabled = !currentFeedData || !!currentFeedData?.pending || isSelectedFeedConnectionBroken || !isAllowedToIssueCompanyCard; - const assignCard = () => { + const assignCard = (cardID?: string) => { + if (isAssigningCardDisabled) { + return; + } + if (isActingAsDelegate) { showDelegateNoAccessModal(); return; @@ -97,6 +101,10 @@ function useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}: UseA bankName: feed, }; + if (cardID) { + data.encryptedCardNumber = cardID; + } + let currentStep: AssignCardStep = CONST.COMPANY_CARD.STEP.ASSIGNEE; const employeeList = Object.values(policy?.employeeList ?? {}).filter((employee) => !isDeletedPolicyEmployee(employee, isOffline)); const isFeedExpired = isSelectedFeedExpired(selectedFeedData); @@ -108,7 +116,7 @@ function useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}: UseA importPlaidAccounts('', selectedFeed, '', country, getDomainNameForPolicy(policyID), '', undefined, undefined, plaidAccessToken); } - if (employeeList.length === 1) { + if (!cardID && employeeList.length === 1) { const userEmail = Object.keys(policy?.employeeList ?? {}).at(0) ?? ''; data.email = userEmail; const personalDetails = getPersonalDetailByEmail(userEmail); @@ -138,7 +146,9 @@ function useAssignCard({selectedFeed, policyID, setShouldShowOfflineModal}: UseA clearAddNewCardFlow(); setAssignCardStepAndData({data, currentStep}); - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed))); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute({policyID, feed: selectedFeed, cardID})); + }); }; return { diff --git a/src/languages/de.ts b/src/languages/de.ts index 957c170387a6..d18cbbe54200 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4851,6 +4851,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU feedName: (feedName: string) => `${feedName}-Karten`, directFeed: 'Direkt-Feed', whoNeedsCardAssigned: 'Wer braucht eine zugewiesene Karte?', + chooseTheCardholder: 'Wähle den Karteninhaber', chooseCard: 'Wähle eine Karte', chooseCardFor: (assignee: string) => `Wähle eine Karte für ${assignee}. Du findest die Karte, die du suchst, nicht? Teile es uns mit.`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 63950ad2df8b..29fba3b40c42 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4736,13 +4736,15 @@ const translations = { feedName: (feedName: string) => `${feedName} cards`, directFeed: 'Direct feed', whoNeedsCardAssigned: 'Who needs a card assigned?', + chooseTheCardholder: 'Choose the cardholder', chooseCard: 'Choose a card', chooseCardFor: (assignee: string) => `Choose a card for ${assignee}. Can't find the card you're looking for? Let us know.`, noActiveCards: 'No active cards on this feed', somethingMightBeBroken: 'Or something might be broken. Either way, if you have any questions, just contact Concierge.', chooseTransactionStartDate: 'Choose a transaction start date', - startDateDescription: "We'll import all transactions from this date onwards. If no date is specified, we’ll go as far back as your bank allows.", + startDateDescription: "Choose your import start date. We'll sync all transactions from this date onwards.", + fromTheBeginning: 'From the beginning', customStartDate: 'Custom start date', customCloseDate: 'Custom close date', diff --git a/src/languages/es.ts b/src/languages/es.ts index 912154c2627d..b117a30ef4ce 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4420,6 +4420,7 @@ ${amount} para ${merchant} - ${date}`, feedName: (feedName) => `Tarjetas ${feedName}`, directFeed: 'Fuente directa', whoNeedsCardAssigned: '¿Quién necesita una tarjeta?', + chooseTheCardholder: 'Elige el titular de la tarjeta', chooseCard: 'Elige una tarjeta', chooseCardFor: (assignee) => `Elige una tarjeta para ${assignee}. ¿No encuentras la tarjeta que buscas? Avísanos.`, noActiveCards: 'No hay tarjetas activas en este feed', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 64ec563648a4..38ac463dd200 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4856,6 +4856,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. feedName: (feedName: string) => `Cartes ${feedName}`, directFeed: 'Flux direct', whoNeedsCardAssigned: 'Qui a besoin d’une carte attribuée ?', + chooseTheCardholder: 'Choisissez le titulaire de la carte', chooseCard: 'Choisissez une carte', chooseCardFor: (assignee: string) => `Choisissez une carte pour ${assignee}. Vous ne trouvez pas la carte que vous recherchez ? Faites-le-nous savoir.`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 8f7ceafd6b05..76f274afcbad 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4834,6 +4834,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. feedName: (feedName: string) => `Carte ${feedName}`, directFeed: 'Feed diretto', whoNeedsCardAssigned: 'Chi ha bisogno di una carta assegnata?', + chooseTheCardholder: 'Scegli il titolare della carta', chooseCard: 'Scegli una carta', chooseCardFor: (assignee: string) => `Scegli una carta per ${assignee}. Non riesci a trovare la carta che stai cercando? Facci sapere.`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7d89a81a11a5..68fa60ae3c0f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4809,6 +4809,7 @@ _より詳しい手順については、[ヘルプサイトをご覧ください feedName: (feedName: string) => `${feedName} カード`, directFeed: 'ダイレクトフィード', whoNeedsCardAssigned: '誰にカードを割り当てる必要がありますか?', + chooseTheCardholder: 'カード所有者を選択', chooseCard: 'カードを選択', chooseCardFor: (assignee: string) => `${assignee} に使うカードを選択してください。お探しのカードが見つかりませんか?お知らせください。`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b09da4a62da8..63be96f36cd1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4829,6 +4829,7 @@ _Voor gedetailleerdere instructies, [bezoek onze helpsite](${CONST.NETSUITE_IMPO feedName: (feedName: string) => `${feedName}-kaarten`, directFeed: 'Directe feed', whoNeedsCardAssigned: 'Wie heeft een kaart toegewezen nodig?', + chooseTheCardholder: 'Kies de kaarthouder', chooseCard: 'Kies een kaart', chooseCardFor: (assignee: string) => `Kies een kaart voor ${assignee}. Kun je de kaart die je zoekt niet vinden? Laat het ons weten.`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index fe35edb31688..c483bdca120b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4821,6 +4821,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy feedName: (feedName: string) => `Karty ${feedName}`, directFeed: 'Bezpośredni kanał', whoNeedsCardAssigned: 'Kto potrzebuje przypisanej karty?', + chooseTheCardholder: 'Wybierz posiadacza karty', chooseCard: 'Wybierz kartę', chooseCardFor: (assignee: string) => `Wybierz kartę dla ${assignee}. Nie możesz znaleźć karty, której szukasz? Daj nam znać.`, noActiveCards: 'Brak aktywnych kart w tym kanale', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 16cf5b6d90e7..d28e78b7c90e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4821,6 +4821,7 @@ _Para instruções mais detalhadas, [visite nosso site de ajuda](${CONST.NETSUIT feedName: (feedName: string) => `Cartões ${feedName}`, directFeed: 'Conexão direta', whoNeedsCardAssigned: 'Quem precisa de um cartão atribuído?', + chooseTheCardholder: 'Escolha o portador do cartão', chooseCard: 'Escolha um cartão', chooseCardFor: (assignee: string) => `Escolha um cartão para ${assignee}. Não encontra o cartão que está procurando? Avise-nos.`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 28acf8fc2717..b8597b316861 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4739,6 +4739,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM feedName: (feedName: string) => `${feedName} 卡片`, directFeed: '直接数据馈送', whoNeedsCardAssigned: '谁需要被分配一张卡?', + chooseTheCardholder: '选择持卡人', chooseCard: '选择一张卡片', chooseCardFor: (assignee: string) => `为 ${assignee} 选择一张卡片。找不到您要找的卡片?请告诉我们。`, noActiveCards: '此信息流中没有有效的卡片', diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2974734b708c..8ab71d4d025e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1202,6 +1202,8 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD]: { policyID: string; feed: CompanyCardFeed; + cardID?: string; + // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx new file mode 100644 index 000000000000..67837e662aa3 --- /dev/null +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -0,0 +1,252 @@ +import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; +import {FlashList} from '@shopify/flash-list'; +import React, {useRef} from 'react'; +import {View} from 'react-native'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {PressableWithFeedback} from '@components/Pressable'; +import SearchBar from '@components/SearchBar'; +import TableRowSkeleton from '@components/Skeletons/TableRowSkeleton'; +import Text from '@components/Text'; +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsList from '@hooks/useCardsList'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; +import { + filterCardsByPersonalDetails, + getCardsByCardholderName, + getCompanyCardFeedWithDomainID, + getCompanyFeeds, + getPlaidInstitutionIconUrl, + getPlaidInstitutionId, + isCustomFeed, + sortCardsByCardholderName, +} from '@libs/CardUtils'; +import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Card, CompanyCardFeed, CompanyCardFeedWithDomainID} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import WorkspaceCompanyCardsFeedAddedEmptyPage from './WorkspaceCompanyCardsFeedAddedEmptyPage'; +import WorkspaceCompanyCardsListRow from './WorkspaceCompanyCardsListRow'; + +type WorkspaceCompanyCardsListProps = { + /** Selected feed */ + selectedFeed: CompanyCardFeedWithDomainID; + + /** Current policy id */ + policyID: string; + + /** On assign card callback */ + onAssignCard: (cardID?: string) => void; + + /** Whether to disable assign card button */ + isAssigningCardDisabled?: boolean; + + /** Whether to show GB disclaimer */ + shouldShowGBDisclaimer?: boolean; +}; + +function WorkspaceCompanyCardsList({selectedFeed, policyID, onAssignCard, isAssigningCardDisabled, shouldShowGBDisclaimer}: WorkspaceCompanyCardsListProps) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {translate, localeCompare} = useLocalize(); + const listRef = useRef>(null); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + + const [cardsList, cardsListMetadata] = useCardsList(selectedFeed); + const isLoadingCardsList = !isOffline && isLoadingOnyxValue(cardsListMetadata); + const [personalDetails, personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const isLoadingPersonalDetails = !isOffline && isLoadingOnyxValue(personalDetailsMetadata); + const isLoadingCardsTableData = isLoadingCardsList || isLoadingPersonalDetails; + + const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES, {canBeMissing: true}); + const policy = usePolicy(policyID); + + const cardListTyped: Record | undefined = (cardsList as {cardList?: Record})?.cardList ?? {}; + const assignedCards = Object.fromEntries(Object.entries(cardsList ?? {}).filter(([key]) => key !== 'cardList')) as Record; + const [cardFeeds] = useCardFeeds(policyID); + + const companyFeeds = getCompanyFeeds(cardFeeds); + const cards = companyFeeds?.[selectedFeed]?.accountList; + + const plaidIconUrl = getPlaidInstitutionIconUrl(selectedFeed); + + // Get all cards sorted by cardholder name + const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); + const allCards = getCardsByCardholderName(cardsList, policyMembersAccountIDs); + + // Filter and sort cards based on search input + const filterCard = (card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails); + const sortCards = (cardsToSort: Card[]) => sortCardsByCardholderName(cardsToSort, personalDetails, localeCompare); + const [inputValue, setInputValue, filteredSortedCards] = useSearchResults(allCards, filterCard, sortCards); + + const isSearchEmpty = filteredSortedCards.length === 0 && inputValue.length > 0; + + // When we reach the medium screen width or the narrow layout is active, + // we want to hide the table header and the middle column of the card rows, so that the content is not overlapping. + const shouldUseNarrowTableRowLayout = isMediumScreenWidth || shouldUseNarrowLayout; + + const renderItem = ({item: cardName, index}: ListRenderItemInfo) => { + const assignedCard = Object.values(assignedCards ?? {}).find((card) => card.cardName === cardName); + + const customCardName = assignedCard?.cardID ? customCardNames?.[assignedCard.cardID] : undefined; + + const isCardDeleted = assignedCard?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + let cardIdentifier: string | undefined; + if (!assignedCard) { + const isPlaid = !!getPlaidInstitutionId(selectedFeed); + const isCommercial = isCustomFeed(selectedFeed); + + if (isPlaid) { + cardIdentifier = cardName; + } else if (isCommercial) { + const cardValue = cardListTyped?.[cardName] ?? cardName; + const digitsOnly = cardValue.replaceAll(/\D/g, ''); + if (digitsOnly.length >= 10) { + const first6 = digitsOnly.substring(0, 6); + const last4 = digitsOnly.substring(digitsOnly.length - 4); + cardIdentifier = `${first6}${last4}`; + } else { + cardIdentifier = cardValue; + } + } else { + cardIdentifier = cardListTyped?.[cardName] ?? cardName; + } + } + + return ( + + { + if (!assignedCard) { + onAssignCard(cardIdentifier); + return; + } + + if (!assignedCard?.accountID || !assignedCard?.fundID) { + return; + } + + return Navigation.navigate( + ROUTES.WORKSPACE_COMPANY_CARD_DETAILS.getRoute( + policyID, + assignedCard.cardID.toString(), + getCompanyCardFeedWithDomainID(assignedCard?.bank as CompanyCardFeed, assignedCard.fundID), + ), + ); + }} + > + {({hovered}) => ( + onAssignCard(cardIdentifier)} + isAssigningCardDisabled={isAssigningCardDisabled} + shouldUseNarrowTableRowLayout={shouldUseNarrowTableRowLayout} + /> + )} + + + ); + }; + + const keyExtractor = (item: string, index: number) => `${item}_${index}`; + + const ListHeaderComponent = shouldUseNarrowTableRowLayout ? ( + + ) : ( + <> + {(cards?.length ?? 0) > CONST.SEARCH_ITEM_LIMIT && ( + + )} + {!isSearchEmpty && ( + + + + {translate('common.member')} + + + + + {translate('workspace.companyCards.card')} + + + + + {translate('workspace.companyCards.cardName')} + + + + )} + + ); + + // Show empty state when there are no cards + if (!cards?.length && !isLoadingCardsTableData) { + return ( + + ); + } + + return ( + + : undefined} + showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + contentContainerStyle={styles.flexGrow1} + /> + + ); +} + +WorkspaceCompanyCardsList.displayName = 'WorkspaceCompanyCardsList'; + +export default WorkspaceCompanyCardsList; diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index 7657c8ceeeaa..2de8af437685 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import ActivityIndicator from '@components/ActivityIndicator'; import DecisionModal from '@components/DecisionModal'; import useAssignCard from '@hooks/useAssignCard'; @@ -20,7 +20,7 @@ import {openPolicyCompanyCardsFeed, openPolicyCompanyCardsPage} from '@userActio import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import WorkspaceCompanyCardPageEmptyState from './WorkspaceCompanyCardPageEmptyState'; import WorkspaceCompanyCardsFeedPendingPage from './WorkspaceCompanyCardsFeedPendingPage'; import WorkspaceCompanyCardsTable from './WorkspaceCompanyCardsTable'; @@ -41,35 +41,29 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY, {canBeMissing: false}); const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`, {canBeMissing: true}); - - const [cardFeeds, , defaultFeed] = useCardFeeds(policyID); + const [cardFeeds] = useCardFeeds(policyID); const selectedFeed = getSelectedFeed(lastSelectedFeed, cardFeeds); const companyFeeds = getCompanyFeeds(cardFeeds); const selectedFeedData = selectedFeed && companyFeeds[selectedFeed]; const feed = selectedFeed ? getCompanyCardFeed(selectedFeed) : undefined; - const [cardsList] = useCardsList(selectedFeed); - + const [cardsList, cardsListMetadata] = useCardsList(selectedFeed); + const hasNoAssignedCard = Object.keys(cardsList ?? {}).length === 0; const isNoFeed = !selectedFeedData; const isFeedPending = !!selectedFeedData?.pending; const isFeedAdded = !isFeedPending && !isNoFeed; - const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false); - const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeedData); + const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeedData) + const {isOffline} = useNetwork({ + onReconnect: () => openPolicyCompanyCardsPage(policyID, domainOrWorkspaceAccountID), + }); + const isLoading = !isOffline && (!cardFeeds || (isFeedAdded && isLoadingOnyxValue(cardsListMetadata))); const isGB = countryByIp === CONST.COUNTRY.GB; - const hasNoAssignedCard = Object.keys(cardsList ?? {}).length === 0; const shouldShowGBDisclaimer = isGB && isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && (isNoFeed || hasNoAssignedCard); - const fetchCompanyCards = useCallback(() => { - openPolicyCompanyCardsPage(policyID, domainOrWorkspaceAccountID); - }, [domainOrWorkspaceAccountID, policyID]); - - const {isOffline} = useNetwork({onReconnect: fetchCompanyCards}); - const isLoading = !isOffline && (!cardFeeds || (!!defaultFeed?.isLoading && isEmptyObject(cardsList))); - useEffect(() => { - fetchCompanyCards(); - }, [fetchCompanyCards]); + openPolicyCompanyCardsPage(policyID, domainOrWorkspaceAccountID); + }, [policyID, domainOrWorkspaceAccountID]); useEffect(() => { if (isLoading || !feed || isFeedPending) { @@ -121,7 +115,6 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) { {isFeedAdded && !isFeedPending && ( ; - /** Current policy id */ policyID: string; /** On assign card callback */ - onAssignCard: () => void; + onAssignCard: (cardID?: string) => void; /** Whether to disable assign card button */ isAssigningCardDisabled?: boolean; @@ -41,15 +41,22 @@ type WorkspaceCompanyCardsTableProps = { shouldShowGBDisclaimer?: boolean; }; -function WorkspaceCompanyCardsTable({selectedFeed, cardsList, policyID, onAssignCard, isAssigningCardDisabled, shouldShowGBDisclaimer}: WorkspaceCompanyCardsTableProps) { +function WorkspaceCompanyCardsTable({selectedFeed, policyID, onAssignCard, isAssigningCardDisabled, shouldShowGBDisclaimer}: WorkspaceCompanyCardsTableProps) { const styles = useThemeStyles(); + const {isOffline} = useNetwork(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const [cardsList, cardsListMetadata] = useCardsList(selectedFeed); + const isLoadingCardsList = !isOffline && isLoadingOnyxValue(cardsListMetadata); + const [personalDetails, personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const isLoadingPersonalDetails = !isOffline && isLoadingOnyxValue(personalDetailsMetadata); + const isLoadingCardsTableData = isLoadingCardsList || isLoadingPersonalDetails; + const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES, {canBeMissing: true}); - const {cardList, ...assignedCards} = cardsList ?? {}; + const cardListTyped: Record | undefined = (cardsList as {cardList?: Record})?.cardList ?? {}; + const assignedCards = Object.fromEntries(Object.entries(cardsList ?? {}).filter(([key]) => key !== 'cardList')) as Record; const [cardFeeds] = useCardFeeds(policyID); const companyFeeds = getCompanyFeeds(cardFeeds); @@ -68,15 +75,38 @@ function WorkspaceCompanyCardsTable({selectedFeed, cardsList, policyID, onAssign const assignedCard = Object.values(assignedCards ?? {}).find(assignedCardPredicate); - const cardholder = personalDetails?.[assignedCard?.accountID ?? CONST.DEFAULT_NUMBER_ID]; + const cardholder = assignedCard?.accountID ? personalDetails?.[assignedCard.accountID] : undefined; - const customCardName = customCardNames?.[assignedCard?.cardID ?? CONST.DEFAULT_NUMBER_ID] ?? getCardDefaultName(cardholder?.displayName); + const customCardName = assignedCard?.cardID ? customCardNames?.[assignedCard.cardID] : undefined; const isCardDeleted = assignedCard?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const isAssigned = !!assignedCard; - return {cardName, customCardName, isCardDeleted, isAssigned, assignedCard, cardholder}; + // Calculate cardIdentifier for unassigned cards + let cardIdentifier: string | undefined; + if (!assignedCard) { + const isPlaid = !!getPlaidInstitutionId(selectedFeed); + const isCommercial = isCustomFeed(selectedFeed); + + if (isPlaid) { + cardIdentifier = cardName; + } else if (isCommercial) { + const cardValue = cardListTyped?.[cardName] ?? cardName; + const digitsOnly = cardValue.replaceAll(/\D/g, ''); + if (digitsOnly.length >= 10) { + const first6 = digitsOnly.substring(0, 6); + const last4 = digitsOnly.substring(digitsOnly.length - 4); + cardIdentifier = `${first6}${last4}`; + } else { + cardIdentifier = cardValue; + } + } else { + cardIdentifier = cardListTyped?.[cardName] ?? cardName; + } + } + + return {cardName, customCardName, isCardDeleted, isAssigned, assignedCard, cardholder, cardIdentifier}; }) ?? []; const renderItem = ({item, index}: ListRenderItemInfo) => ( @@ -87,7 +117,7 @@ function WorkspaceCompanyCardsTable({selectedFeed, cardsList, policyID, onAssign selectedFeed={selectedFeed} plaidIconUrl={getPlaidInstitutionIconUrl(selectedFeed)} isPlaidCardFeed={isPlaidCardFeed} - onAssignCard={onAssignCard} + onAssignCard={() => onAssignCard(item.cardIdentifier)} isAssigningCardDisabled={isAssigningCardDisabled} shouldUseNarrowTableRowLayout={shouldShowNarrowLayout} /> @@ -222,7 +252,7 @@ function WorkspaceCompanyCardsTable({selectedFeed, cardsList, policyID, onAssign }, [activeSortingInWideLayout, shouldShowNarrowLayout]); // Show empty state when there are no cards - if (!data.length) { + if (!data.length && !isLoadingCardsTableData) { return ( onAssignCard()} isAssigningCardDisabled={isAssigningCardDisabled} /> @@ -241,7 +271,7 @@ function WorkspaceCompanyCardsTable({selectedFeed, cardsList, policyID, onAssign return ( : undefined} > Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed))); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute({policyID, feed: selectedFeed}))); return; } setAssignCardStepAndData({data, currentStep: CONST.COMPANY_CARD.STEP.BANK_CONNECTION}); - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed))); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute({policyID, feed: selectedFeed}))); }; const secondaryActions = [ diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableItem.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableItem.tsx index b20d0d816acc..384182b87d1f 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableItem.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsTableItem.tsx @@ -40,6 +40,9 @@ type WorkspaceCompanyCardTableItemData = { /** Whether the card is assigned */ isAssigned: boolean; + + /** Card identifier for unassigned cards */ + cardIdentifier?: string; }; type WorkspaceCompanyCardTableItemProps = { diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx index 938b798f725a..0bffd8283237 100644 --- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx @@ -4,13 +4,14 @@ import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import useInitial from '@hooks/useInitial'; import useOnyx from '@hooks/useOnyx'; +import {getCompanyCardFeed} from '@libs/CardUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; import PlaidConnectionStep from '@pages/workspace/companyCards/addNew/PlaidConnectionStep'; import BankConnection from '@pages/workspace/companyCards/BankConnection'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; -import {clearAssignCardStepAndData} from '@userActions/CompanyCards'; +import {clearAssignCardStepAndData, setAssignCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -25,10 +26,12 @@ import TransactionStartDateStep from './TransactionStartDateStep'; type AssignCardFeedPageProps = PlatformStackScreenProps & WithPolicyAndFullscreenLoadingProps; function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { + const feed = decodeURIComponent(route.params?.feed) as CompanyCardFeedWithDomainID; + const cardID = route.params?.cardID ? decodeURIComponent(route.params?.cardID) : undefined; + const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD, {canBeMissing: true}); const currentStep = assignCard?.currentStep; - const feed = decodeURIComponent(route.params?.feed) as CompanyCardFeedWithDomainID; const backTo = route.params?.backTo; const policyID = policy?.id; const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector, canBeMissing: true}); @@ -41,6 +44,21 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { }; }, []); + useEffect(() => { + if (!cardID || currentStep) { + return; + } + const companyCardFeed = getCompanyCardFeed(feed); + + setAssignCardStepAndData({ + currentStep: CONST.COMPANY_CARD.STEP.ASSIGNEE, + data: { + bankName: companyCardFeed, + encryptedCardNumber: cardID, + }, + }); + }, [cardID, currentStep, feed]); + if (isActingAsDelegate) { return ( ); @@ -84,7 +101,7 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { /> ); case CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE: - return ; + return ; case CONST.COMPANY_CARD.STEP.CARD_NAME: return ; case CONST.COMPANY_CARD.STEP.CONFIRMATION: @@ -106,7 +123,6 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { return ( ); diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index b435955b4877..7851c9f0f9e5 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -1,15 +1,14 @@ +import {format} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useEffect, useMemo, useState} from 'react'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import * as Expensicons from '@components/Icon/Expensicons'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; -import useCardFeeds from '@hooks/useCardFeeds'; -import useCardsList from '@hooks/useCardsList'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -17,7 +16,7 @@ import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import {setDraftInviteAccountID} from '@libs/actions/Card'; import {searchInServer} from '@libs/actions/Report'; -import {getDefaultCardName, getFilteredCardList, hasOnlyOneCardToAssign} from '@libs/CardUtils'; +import {getDefaultCardName} from '@libs/CardUtils'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getHeaderMessage, getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; @@ -36,24 +35,18 @@ type AssigneeStepProps = { /** The policy that the card will be issued under */ policy: OnyxEntry; - /** Selected feed */ - feed: OnyxTypes.CompanyCardFeedWithDomainID; - /** Route params */ route: PlatformStackRouteProp; }; -function AssigneeStep({policy, feed, route}: AssigneeStepProps) { +function AssigneeStep({policy, route}: AssigneeStepProps) { const policyID = route.params.policyID; const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar'] as const); const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD, {canBeMissing: true}); - const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: false}); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); - const [list] = useCardsList(feed); - const [cardFeeds] = useCardFeeds(policyID); - const filteredCardList = getFilteredCardList(list, cardFeeds?.[feed]?.accountList, workspaceCardFeeds); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); @@ -89,9 +82,18 @@ function AssigneeStep({policy, feed, route}: AssigneeStepProps) { }; Keyboard.dismiss(); + if (assignee?.login === assignCard?.data?.email) { + if (assignCard?.data?.encryptedCardNumber) { + nextStep = CONST.COMPANY_CARD.STEP.CONFIRMATION; + data.encryptedCardNumber = assignCard.data.encryptedCardNumber; + data.cardNumber = assignCard.data.cardNumber; + data.startDate = data.startDate ?? format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + data.dateOption = data.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; + } setAssignCardStepAndData({ currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : nextStep, + data, isEditing: false, }); return; @@ -109,10 +111,12 @@ function AssigneeStep({policy, feed, route}: AssigneeStepProps) { return; } - if (hasOnlyOneCardToAssign(filteredCardList)) { - nextStep = CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE; - data.cardNumber = Object.keys(filteredCardList).at(0); - data.encryptedCardNumber = Object.values(filteredCardList).at(0); + if (assignCard?.data?.encryptedCardNumber) { + nextStep = CONST.COMPANY_CARD.STEP.CONFIRMATION; + data.encryptedCardNumber = assignCard.data.encryptedCardNumber; + data.cardNumber = assignCard.data.cardNumber; + data.startDate = data.startDate ?? format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + data.dateOption = data.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; } setAssignCardStepAndData({ @@ -154,7 +158,7 @@ function AssigneeStep({policy, feed, route}: AssigneeStepProps) { isSelected: assignCard?.data?.email === email, icons: [ { - source: personalDetail?.avatar ?? Expensicons.FallbackAvatar, + source: personalDetail?.avatar ?? icons.FallbackAvatar, name: formatPhoneNumber(email), type: CONST.ICON_TYPE_AVATAR, id: personalDetail?.accountID, @@ -166,7 +170,7 @@ function AssigneeStep({policy, feed, route}: AssigneeStepProps) { membersList = sortAlphabetically(membersList, 'text', localeCompare); return membersList; - }, [isOffline, policy?.employeeList, assignCard?.data?.email, formatPhoneNumber, localeCompare]); + }, [isOffline, policy?.employeeList, assignCard?.data?.email, formatPhoneNumber, localeCompare, icons.FallbackAvatar]); const assignees = useMemo(() => { if (!debouncedSearchTerm) { @@ -229,13 +233,11 @@ function AssigneeStep({policy, feed, route}: AssigneeStepProps) { setDidScreenTransitionEnd(true)} > - {translate('workspace.companyCards.whoNeedsCardAssigned')} + {translate('workspace.companyCards.chooseTheCardholder')} {translate('workspace.moreFeatures.companyCards.giveItNameInstruction')} state?.routes?.findLast((route) => isFullScreenName(route.name))); @@ -98,17 +103,14 @@ function ConfirmationStep({policyID, feed, backTo}: ConfirmationStepProps) { }; const handleBackButtonPress = () => { - setAssignCardStepAndData({currentStep: CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE}); + setAssignCardStepAndData({currentStep: CONST.COMPANY_CARD.STEP.ASSIGNEE}); }; return ( @@ -119,18 +121,20 @@ function ConfirmationStep({policyID, feed, backTo}: ConfirmationStepProps) { > {translate('workspace.companyCards.letsDoubleCheck')} {translate('workspace.companyCards.confirmationDescription')} - editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)} - /> + editStep(CONST.COMPANY_CARD.STEP.CARD)} + onPress={() => editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)} /> }) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -38,7 +42,13 @@ function TransactionStartDateStep() { }); return; } - setAssignCardStepAndData({currentStep: CONST.COMPANY_CARD.STEP.CARD}); + const backTo = route?.params?.backTo; + if (backTo) { + Navigation.goBack(backTo); + return; + } + const nextStep = data?.encryptedCardNumber ? CONST.COMPANY_CARD.STEP.ASSIGNEE : CONST.COMPANY_CARD.STEP.CARD; + setAssignCardStepAndData({currentStep: nextStep}); }; const handleSelectDateOption = (dateOption: string) => { @@ -68,35 +78,29 @@ function TransactionStartDateStep() { }); }; - const dateOptions = useMemo( - () => [ - { - 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, - }, - ], - [dateOptionSelected, translate], - ); + 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.chooseTransactionStartDate')} {translate('workspace.companyCards.startDateDescription')} } diff --git a/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx b/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx index 737c543337a6..5a12e2fcee87 100644 --- a/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx @@ -123,7 +123,9 @@ function WorkspaceMemberNewCardPage({route, personalDetails}: WorkspaceMemberNew isEditing: false, }); Navigation.setNavigationActionToMicrotaskQueue(() => - Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed, ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID))), + Navigation.navigate( + ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute({policyID, feed: selectedFeed, cardID: undefined}, ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)), + ), ); } };