diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2bff68a44111..dbf9a74a32aa 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1276,6 +1276,7 @@ const CONST = { MAX_COUNT_BEFORE_FOCUS_UPDATE: 30, MIN_INITIAL_REPORT_ACTION_COUNT: 15, UNREPORTED_REPORT_ID: '0', + TRASH_REPORT_ID: '-1', SPLIT_REPORT_ID: '-2', SECONDARY_ACTIONS: { SUBMIT: 'submit', @@ -7345,6 +7346,7 @@ const CONST = { DONE: 'done', EXPORT_TO_ACCOUNTING: 'exportToAccounting', PAID: 'paid', + UNDELETE: 'undelete', }, HAS_VALUES: { RECEIPT: 'receipt', @@ -7365,6 +7367,7 @@ const CONST = { REJECT: 'reject', CHANGE_REPORT: 'changeReport', SPLIT: 'split', + UNDELETE: 'undelete', }, TRANSACTION_TYPE: { CASH: 'cash', @@ -7599,6 +7602,7 @@ const CONST = { APPROVED: 'approved', DONE: 'done', PAID: 'paid', + DELETED: 'deleted', }, EXPENSE_REPORT: { ALL: '', diff --git a/src/components/Search/SearchList/ListItem/ActionCell/actionTranslationsMap.ts b/src/components/Search/SearchList/ListItem/ActionCell/actionTranslationsMap.ts index b27d9ee4f090..90d490038bd3 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/actionTranslationsMap.ts +++ b/src/components/Search/SearchList/ListItem/ActionCell/actionTranslationsMap.ts @@ -10,6 +10,7 @@ const actionTranslationsMap: Record = [CONST.SEARCH.ACTION_TYPES.EXPORT_TO_ACCOUNTING]: 'common.export', [CONST.SEARCH.ACTION_TYPES.DONE]: 'common.done', [CONST.SEARCH.ACTION_TYPES.PAID]: 'iou.settledExpensify', + [CONST.SEARCH.ACTION_TYPES.UNDELETE]: 'search.bulkActions.undelete', }; export default actionTranslationsMap; diff --git a/src/components/Search/SearchList/ListItem/StatusCell.tsx b/src/components/Search/SearchList/ListItem/StatusCell.tsx index 87b3797a61fd..d0326959178e 100644 --- a/src/components/Search/SearchList/ListItem/StatusCell.tsx +++ b/src/components/Search/SearchList/ListItem/StatusCell.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import StatusBadge from '@components/StatusBadge'; import useLocalize from '@hooks/useLocalize'; @@ -15,15 +15,18 @@ type StatusCellProps = { /** Whether the report's state/status is pending */ isPending?: boolean; + + /** Whether the transaction was deleted */ + isDeleted?: boolean; }; -function StatusCell({stateNum, statusNum, isPending}: StatusCellProps) { +function StatusCell({stateNum, statusNum, isPending, isDeleted}: StatusCellProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); - const statusText = useMemo(() => getReportStatusTranslation({stateNum, statusNum, translate}), [stateNum, statusNum, translate]); - const reportStatusColorStyle = useMemo(() => getReportStatusColorStyle(theme, stateNum, statusNum), [theme, stateNum, statusNum]); + const statusText = getReportStatusTranslation({stateNum, statusNum, isDeleted, translate}); + const reportStatusColorStyle = getReportStatusColorStyle(theme, stateNum, statusNum, isDeleted); if (!statusText || !reportStatusColorStyle) { return null; diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem.tsx index 8dbb9a127cad..cf6e0d5fbd38 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem.tsx @@ -24,7 +24,7 @@ import {handleActionButtonPress as handleActionButtonPressUtil} from '@libs/acti import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isInvoiceReport} from '@libs/ReportUtils'; -import {isViolationDismissed, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils'; +import {isDeletedTransaction as isDeletedTransactionUtil, isViolationDismissed, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -51,8 +51,10 @@ function TransactionListItem({ customCardNames, lastPaymentMethod, personalPolicyID, + onUndelete, }: TransactionListItemProps) { const transactionItem = item as unknown as TransactionListItemType; + const isDeletedTransaction = isDeletedTransactionUtil(transactionItem); const styles = useThemeStyles(); const theme = useTheme(); @@ -163,6 +165,7 @@ function TransactionListItem({ isDelegateAccessRestricted, onDelegateAccessRestricted: showDelegateNoAccessModal, personalPolicyID, + onUndelete: () => onUndelete?.(transactionItem.transactionID), }); }; @@ -176,10 +179,10 @@ function TransactionListItem({ onLongPressRow?.(item)} - onPress={() => onSelectRow(item, transactionPreviewData)} + onPress={isDeletedTransaction ? undefined : () => onSelectRow(item, transactionPreviewData)} disabled={isDisabled && !item.isSelected} accessibilityLabel={item.text ?? ''} - role={getButtonRole(true)} + role={isDeletedTransaction ? getButtonRole(true) : 'none'} isNested onMouseDown={(e) => e.preventDefault()} hoverStyle={[!item.isDisabled && styles.hoveredComponentBG, item.isSelected && styles.activeComponentBG]} @@ -189,6 +192,7 @@ function TransactionListItem({ style={[ pressableStyle, isFocused && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), + isDeletedTransaction && styles.cursorDefault, ]} onFocus={onFocus} wrapperStyle={[styles.mb2, styles.mh5, styles.flex1, animatedHighlightStyle, styles.userSelectNone]} @@ -199,7 +203,7 @@ function TransactionListItem({ @@ -227,7 +231,7 @@ function TransactionListItem({ checkboxSentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM_CHECKBOX} style={[styles.p3, styles.pv2, shouldUseNarrowLayout ? styles.pt2 : {}]} violations={transactionViolations} - onArrowRightPress={() => onSelectRow(item, transactionPreviewData)} + onArrowRightPress={isDeletedTransaction ? undefined : () => onSelectRow(item, transactionPreviewData)} isHover={hovered} customCardNames={customCardNames} reportActions={exportedReportActions} diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index b564d34cf155..e01b87817d5c 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -401,6 +401,8 @@ type TransactionListItemProps = ListItemProps & { lastPaymentMethod?: OnyxEntry; /** The user's personal policy ID */ personalPolicyID?: string; + /** Callback to undelete a transaction by its ID */ + onUndelete?: (transactionID: string) => void; }; type TransactionGroupListItemProps = ListItemProps & { diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 73fd8a20e432..e9dd35bd5920 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -27,6 +27,7 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useThemeStyles from '@hooks/useThemeStyles'; +import useUndeleteTransactions from '@hooks/useUndeleteTransactions'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import DateUtils from '@libs/DateUtils'; @@ -301,6 +302,9 @@ function SearchList({ const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); + const undeleteTransactions = useUndeleteTransactions(); + + const handleUndelete = (transactionID: string) => undeleteTransactions([transactionID]); const route = useRoute(); const {getScrollOffset} = useContext(ScrollOffsetContext); @@ -450,6 +454,7 @@ function SearchList({ customCardNames={customCardNames} onFocus={onFocus} newTransactionID={newTransactionID} + onUndelete={handleUndelete} keyForList={item.keyForList} /> @@ -482,6 +487,7 @@ function SearchList({ personalPolicyID, customCardNames, selectedTransactions, + handleUndelete, ], ); diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index b3c999f9ab7f..4f2d5c900048 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -39,6 +39,7 @@ import { getCreated as getTransactionCreated, hasMissingSmartscanFields, isAmountMissing, + isDeletedTransaction as isDeletedTransactionUtil, isMerchantMissing, isScanning, isTimeRequest, @@ -203,6 +204,7 @@ function TransactionItemRow({ const createdAt = getTransactionCreated(transactionItem); const expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight']); const transactionThreadReportID = reportActions ? getIOUActionForTransactionID(reportActions, transactionItem.transactionID)?.childReportID : undefined; + const isDeletedTransaction = isDeletedTransactionUtil(transactionItem); const isDateColumnWide = dateColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; const isSubmittedColumnWide = submittedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE; @@ -615,6 +617,7 @@ function TransactionItemRow({ ); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index bf6564e974da..38fa9f5119d8 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -47,7 +47,7 @@ import { } from '@libs/ReportUtils'; import {navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {hasTransactionBeenRejected} from '@libs/TransactionUtils'; +import {hasTransactionBeenRejected, isDeletedTransaction} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import {canIOUBePaid, dismissRejectUseExplanation} from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -67,6 +67,7 @@ import usePersonalPolicy from './usePersonalPolicy'; import useSelfDMReport from './useSelfDMReport'; import useTheme from './useTheme'; import useThemeStyles from './useThemeStyles'; +import useUndeleteTransactions from './useUndeleteTransactions'; type SearchHeaderOptionValue = DeepValueOf | undefined; @@ -103,6 +104,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const personalPolicy = usePersonalPolicy(); const [userBillingGraceEndPeriods] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const undeleteTransactions = useUndeleteTransactions(); // Cache the last search results that had data, so the merge option remains available // while results are temporarily unset (e.g. during sorting/loading). @@ -143,6 +145,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { 'Exclamation', 'MoneyBag', 'ArrowSplit', + 'RotateLeft', 'QBOSquare', 'XeroSquare', 'NetSuiteSquare', @@ -681,6 +684,23 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return CONST.EMPTY_ARRAY as unknown as Array>; } + const allSelectedAreDeleted = selectedTransactionsKeys.length > 0 && selectedTransactionsKeys.every((id) => isDeletedTransaction(selectedTransactions[id] ?? {})); + + if (allSelectedAreDeleted) { + return [ + { + icon: expensifyIcons.RotateLeft, + text: translate('search.bulkActions.undelete'), + value: CONST.SEARCH.BULK_ACTION_TYPES.UNDELETE, + shouldCloseModalOnSelect: true, + onSelected: () => { + undeleteTransactions(selectedTransactionsKeys); + clearSelectedTransactions(); + }, + }, + ]; + } + const options: Array> = []; const isAnyTransactionOnHold = Object.values(selectedTransactions).some((transaction) => transaction.isHeld); @@ -1153,6 +1173,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { expensifyIcons.DocumentMerge, expensifyIcons.ArrowSplit, expensifyIcons.Trashcan, + expensifyIcons.RotateLeft, expensifyIcons.Exclamation, translate, areAllMatchingItemsSelected, @@ -1194,6 +1215,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { firstTransaction, firstTransactionPolicy, handleDeleteSelectedTransactions, + undeleteTransactions, + currentUserPersonalDetails?.email, theme.icon, styles.colorMuted, styles.fontWeightNormal, diff --git a/src/hooks/useUndeleteTransactions.ts b/src/hooks/useUndeleteTransactions.ts new file mode 100644 index 000000000000..bc4e8452920e --- /dev/null +++ b/src/hooks/useUndeleteTransactions.ts @@ -0,0 +1,33 @@ +import {changeTransactionsReport} from '@libs/actions/Transaction'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useAllTransactions from './useAllTransactions'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; + +function useUndeleteTransactions() { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const allTransactions = useAllTransactions(); + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const {translate, toLocaleDigit} = useLocalize(); + const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${personalPolicyID}`); + + return (transactionIDs: string[]) => { + changeTransactionsReport({ + transactionIDs, + isASAPSubmitBetaEnabled, + accountID: currentUserPersonalDetails.accountID ?? CONST.DEFAULT_NUMBER_ID, + email: currentUserPersonalDetails.email ?? '', + policy, + allTransactions, + translate, + toLocaleDigit, + }); + }; +} + +export default useUndeleteTransactions; diff --git a/src/languages/de.ts b/src/languages/de.ts index d1773b887b64..333a92aff08e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1605,6 +1605,7 @@ const translations: TranslationDeepObject = { failedToApproveViaDEW: (reason: string) => `Genehmigung fehlgeschlagen. ${reason}`, cannotDuplicateDistanceExpense: 'Sie können Entfernungsausgaben nicht über mehrere Arbeitsbereiche hinweg duplizieren, da sich die Sätze zwischen den Arbeitsbereichen unterscheiden können.', + deleted: 'Gelöscht', }, transactionMerge: { listPage: { @@ -7327,6 +7328,7 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und unhold: 'Zurückhalten aufheben', reject: 'Ablehnen', noOptionsAvailable: 'Für die ausgewählte Ausgabengruppe sind keine Optionen verfügbar.', + undelete: 'Wiederherstellen', }, filtersHeader: 'Filter', filters: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 60bc4dd47a11..7305c159d1c9 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1356,6 +1356,7 @@ const translations = { }), settledExpensify: 'Paid', done: 'Done', + deleted: 'Deleted', settledElsewhere: 'Paid elsewhere', individual: 'Individual', business: 'Business', @@ -7316,6 +7317,7 @@ const translations = { hold: 'Hold', unhold: 'Remove hold', reject: 'Reject', + undelete: 'Undelete', noOptionsAvailable: 'No options available for the selected group of expenses.', }, filtersHeader: 'Filters', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5d06b64cda74..b7708b5271eb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1224,6 +1224,7 @@ const translations: TranslationDeepObject = { }), settledExpensify: 'Pagado', done: 'Listo', + deleted: 'Eliminado', settledElsewhere: 'Pagado de otra forma', individual: 'Individual', business: 'Empresa', @@ -7218,6 +7219,7 @@ ${amount} para ${merchant} - ${date}`, hold: 'Retener', unhold: 'Desbloquear', reject: 'Rechazar', + undelete: 'Restaurar', noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.', }, filtersHeader: 'Filtros', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0038fe534f55..63e01d10160e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1610,6 +1610,7 @@ const translations: TranslationDeepObject = { `impossible d’approuver via les règles de l’espace de travail. ${reason}`, failedToApproveViaDEW: (reason: string) => `échec de l’approbation. ${reason}`, cannotDuplicateDistanceExpense: 'Vous ne pouvez pas dupliquer des dépenses de distance entre espaces de travail, car les taux peuvent différer d’un espace de travail à l’autre.', + deleted: 'Supprimé', }, transactionMerge: { listPage: { @@ -7351,6 +7352,7 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip unhold: 'Supprimer la mise en attente', reject: 'Rejeter', noOptionsAvailable: 'Aucune option n’est disponible pour le groupe de dépenses sélectionné.', + undelete: 'Restaurer', }, filtersHeader: 'Filtres', filters: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 3cdcd967b43a..1ce21ee538db 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1603,6 +1603,7 @@ const translations: TranslationDeepObject = { `approvazione non riuscita tramite le regole dello spazio di lavoro. ${reason}`, failedToApproveViaDEW: (reason: string) => `approvazione non riuscita. ${reason}`, cannotDuplicateDistanceExpense: 'Non puoi duplicare le spese chilometriche tra diversi spazi di lavoro perché le tariffe potrebbero essere diverse.', + deleted: 'Eliminato', }, transactionMerge: { listPage: { @@ -7315,6 +7316,7 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo unhold: 'Rimuovi blocco', reject: 'Rifiuta', noOptionsAvailable: 'Nessuna opzione disponibile per il gruppo di spese selezionato.', + undelete: 'Ripristina', }, filtersHeader: 'Filtri', filters: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d8d6b48c73e2..dc6d955a598a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1583,6 +1583,7 @@ const translations: TranslationDeepObject = { failedToAutoApproveViaDEW: (reason: string) => `ワークスペースルールで承認に失敗しました。${reason}`, failedToApproveViaDEW: (reason: string) => `承認に失敗しました。${reason}`, cannotDuplicateDistanceExpense: '距離精算はワークスペースごとにレートが異なる可能性があるため、ワークスペース間で複製することはできません。', + deleted: '削除済み', }, transactionMerge: { listPage: { @@ -7229,6 +7230,7 @@ ${reportName} unhold: '保留を解除', reject: '却下', noOptionsAvailable: '選択した経費グループには利用できるオプションがありません。', + undelete: '削除を取り消す', }, filtersHeader: 'フィルター', filters: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6c8670a9375a..0b2dcf392e8f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1599,6 +1599,7 @@ const translations: TranslationDeepObject = { failedToAutoApproveViaDEW: (reason: string) => `goedkeuren via werkruimte­regels is mislukt. ${reason}`, failedToApproveViaDEW: (reason: string) => `goedkeuren mislukt. ${reason}`, cannotDuplicateDistanceExpense: 'Je kunt afstandsvergoedingen niet dupliceren tussen werkruimtes, omdat de tarieven per werkruimte kunnen verschillen.', + deleted: 'Verwijderd', }, transactionMerge: { listPage: { @@ -7293,6 +7294,7 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar unhold: 'Blokkering opheffen', reject: 'Afwijzen', noOptionsAvailable: 'Geen opties beschikbaar voor de geselecteerde groep onkosten.', + undelete: 'Terugzetten', }, filtersHeader: 'Filters', filters: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1a80b954af5b..22c7b501defe 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1600,6 +1600,7 @@ const translations: TranslationDeepObject = { `nie udało się zatwierdzić przez zasady w przestrzeni roboczej. ${reason}`, failedToApproveViaDEW: (reason: string) => `nie udało się zaakceptować. ${reason}`, cannotDuplicateDistanceExpense: 'Nie możesz duplikować wydatków za przejazdy między przestrzeniami roboczymi, ponieważ stawki mogą się różnić między poszczególnymi przestrzeniami.', + deleted: 'Usunięto', }, transactionMerge: { listPage: { @@ -7282,6 +7283,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i unhold: 'Usuń blokadę', reject: 'Odrzuć', noOptionsAvailable: 'Brak opcji dostępnych dla wybranej grupy wydatków.', + undelete: 'Cofnij usunięcie', }, filtersHeader: 'Filtry', filters: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 6226e66c8f91..a31e57a3fd59 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1596,6 +1596,7 @@ const translations: TranslationDeepObject = { failedToAutoApproveViaDEW: (reason: string) => `falha ao aprovar pelas regras do workspace. ${reason}`, failedToApproveViaDEW: (reason: string) => `falha ao aprovar. ${reason}`, cannotDuplicateDistanceExpense: 'Você não pode duplicar despesas de distância entre espaços de trabalho porque as tarifas podem ser diferentes entre eles.', + deleted: 'Excluído', }, transactionMerge: { listPage: { @@ -7285,6 +7286,7 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e unhold: 'Remover bloqueio', reject: 'Rejeitar', noOptionsAvailable: 'Nenhuma opção disponível para o grupo de despesas selecionado.', + undelete: 'Restaurar', }, filtersHeader: 'Filtros', filters: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8ed909dd956a..6e2544acc9a2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1554,6 +1554,7 @@ const translations: TranslationDeepObject = { failedToAutoApproveViaDEW: (reason: string) => `未能通过工作区规则批准。${reason}`, failedToApproveViaDEW: (reason: string) => `批准失败。${reason}`, cannotDuplicateDistanceExpense: '你无法在不同工作区之间复制里程报销,因为各个工作区的费率可能不同。', + deleted: '已删除', }, transactionMerge: { listPage: { @@ -7105,6 +7106,7 @@ ${reportName} unhold: '解除保留', reject: '拒绝', noOptionsAvailable: '所选报销的费用组没有可用选项。', + undelete: '取消删除', }, filtersHeader: '筛选器', filters: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 41df98e7e2d1..58fc36288e79 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -993,6 +993,7 @@ type GetReportNameParams = { type GetReportStatusParams = { stateNum?: number; statusNum?: number; + isDeleted?: boolean; translate: LocaleContextProps['translate']; }; @@ -13065,7 +13066,10 @@ function buildOptimisticMarkedAsResolvedReportAction(created = DateUtils.getDBTi * ======================================== */ -function getReportStatusTranslation({stateNum, statusNum, translate}: GetReportStatusParams): string { +function getReportStatusTranslation({stateNum, statusNum, isDeleted, translate}: GetReportStatusParams): string { + if (isDeleted) { + return translate('iou.deleted'); + } if (stateNum === undefined || statusNum === undefined) { return ''; } @@ -13093,7 +13097,10 @@ function getReportStatusTranslation({stateNum, statusNum, translate}: GetReportS return ''; } -function getReportStatusColorStyle(theme: ThemeColors, stateNum?: number, statusNum?: number): {backgroundColor?: ColorValue; textColor?: ColorValue} | undefined { +function getReportStatusColorStyle(theme: ThemeColors, stateNum?: number, statusNum?: number, isDeleted?: boolean): {backgroundColor?: ColorValue; textColor?: ColorValue} | undefined { + if (isDeleted) { + return theme.reportStatusBadge.deleted; + } if (stateNum === undefined || statusNum === undefined) { return undefined; } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 2ea11db30963..bea5a640f35f 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -165,6 +165,7 @@ import { getAmount as getTransactionAmount, getCreated as getTransactionCreatedDate, getMerchant as getTransactionMerchant, + isDeletedTransaction, isPending, isScanning, isViolationDismissed, @@ -342,18 +343,18 @@ const transactionQuarterGroupColumnNamesToSortingProperty: TransactionQuarterGro ...transactionGroupBaseSortingProperties, }; -const expenseStatusActionMapping = { - [CONST.SEARCH.STATUS.EXPENSE.DRAFTS]: (expenseReport?: OnyxTypes.Report) => - expenseReport?.stateNum === CONST.REPORT.STATE_NUM.OPEN && expenseReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN, - [CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING]: (expenseReport?: OnyxTypes.Report) => +type ExpenseStatusPredicate = (expenseReport?: OnyxTypes.Report, transactionReportID?: string) => boolean; + +const expenseStatusActionMapping: Record = { + [CONST.SEARCH.STATUS.EXPENSE.DRAFTS]: (expenseReport) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.OPEN && expenseReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN, + [CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING]: (expenseReport) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && expenseReport.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED, - [CONST.SEARCH.STATUS.EXPENSE.APPROVED]: (expenseReport?: OnyxTypes.Report) => - expenseReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && expenseReport.statusNum === CONST.REPORT.STATUS_NUM.APPROVED, - [CONST.SEARCH.STATUS.EXPENSE.PAID]: (expenseReport?: OnyxTypes.Report) => + [CONST.SEARCH.STATUS.EXPENSE.APPROVED]: (expenseReport) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && expenseReport.statusNum === CONST.REPORT.STATUS_NUM.APPROVED, + [CONST.SEARCH.STATUS.EXPENSE.PAID]: (expenseReport) => (expenseReport?.stateNum ?? 0) >= CONST.REPORT.STATE_NUM.APPROVED && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED, - [CONST.SEARCH.STATUS.EXPENSE.DONE]: (expenseReport?: OnyxTypes.Report) => - expenseReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && expenseReport.statusNum === CONST.REPORT.STATUS_NUM.CLOSED, - [CONST.SEARCH.STATUS.EXPENSE.UNREPORTED]: (expenseReport?: OnyxTypes.Report) => !expenseReport, + [CONST.SEARCH.STATUS.EXPENSE.DONE]: (expenseReport) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && expenseReport.statusNum === CONST.REPORT.STATUS_NUM.CLOSED, + [CONST.SEARCH.STATUS.EXPENSE.UNREPORTED]: (expenseReport, transactionReportID) => !expenseReport && transactionReportID !== CONST.REPORT.TRASH_REPORT_ID, + [CONST.SEARCH.STATUS.EXPENSE.DELETED]: (_expenseReport, transactionReportID) => transactionReportID === CONST.REPORT.TRASH_REPORT_ID, [CONST.SEARCH.STATUS.EXPENSE.ALL]: () => true, }; @@ -392,6 +393,7 @@ function getExpenseStatusOptions(translate: LocalizedTranslate): Array { - return isValidExpenseStatus(expenseStatus) ? expenseStatusActionMapping[expenseStatus](report) : false; + return isValidExpenseStatus(expenseStatus) ? expenseStatusActionMapping[expenseStatus](report, transactionItem.reportID) : false; }); } else { - shouldShow = isValidExpenseStatus(status) ? expenseStatusActionMapping[status](report) : false; + shouldShow = isValidExpenseStatus(status) ? expenseStatusActionMapping[status](report, transactionItem.reportID) : false; } } } @@ -1936,6 +1938,11 @@ function getActions( } const transaction = isTransaction ? data[key] : undefined; + + if (transaction && isDeletedTransaction(transaction)) { + return [CONST.SEARCH.ACTION_TYPES.UNDELETE]; + } + // Tracked and unreported expenses don't have a report, so we return early. if (!report) { return [CONST.SEARCH.ACTION_TYPES.VIEW]; @@ -2109,6 +2116,7 @@ function createAndOpenSearchTransactionThread( shouldNavigate = true, ) { const isFromSelfDM = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const isDeleted = isDeletedTransaction(item); const iouReportAction = getIOUActionForReportID(isFromSelfDM ? findSelfDMReportID() : item.reportID, item.transactionID); const moneyRequestReportActionID = item.reportAction?.reportActionID ?? undefined; const previewData = transactionPreviewData @@ -2122,16 +2130,16 @@ function createAndOpenSearchTransactionThread( // The transaction thread can be created from the chat page and the snapshot data is stale if (hasActualTransactionThread) { transactionThreadReport = getReportOrDraftReport(iouReportAction.childReportID); - } else { - // For legacy transactions without an IOU action in the backend, pass transaction data - // This allows OpenReport to create the IOU action and transaction thread on the backend + } + + // Only create a new thread when there's no existing childReportID. + // When childReportID exists but the report isn't in Onyx (e.g. search snapshot didn't include it), + // skip creation so the navigation below falls back to the real childReportID. + if (!transactionThreadReport && !hasActualTransactionThread) { const reportActionID = moneyRequestReportActionID ?? iouReportAction?.reportActionID; - // Pass transaction data when there's no reportActionID OR when the item is from self DM - // (unreported transactions have a valid reportActionID but still need transaction data for proper detection) const shouldPassTransactionData = !reportActionID || isFromSelfDM; const transaction = shouldPassTransactionData ? getTransactionFromTransactionListItem(item) : undefined; const transactionViolations = shouldPassTransactionData ? item.violations : undefined; - // Use the full reportAction to preserve originalMessage.type (e.g., "track") for proper expense type detection const reportActionToPass = iouReportAction ?? item.reportAction ?? ({reportActionID} as OnyxTypes.ReportAction); transactionThreadReport = createTransactionThreadReport( introSelected, @@ -2147,7 +2155,7 @@ function createAndOpenSearchTransactionThread( if (shouldNavigate) { // Navigate to transaction thread if there are multiple transactions in the report, or to the parent report if it's the only transaction const isFromOneTransactionReport = isOneTransactionReport(item.report); - const shouldNavigateToTransactionThread = (!isFromOneTransactionReport || isFromSelfDM) && transactionThreadReport?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID; + const shouldNavigateToTransactionThread = (!isFromOneTransactionReport || isFromSelfDM || isDeleted) && transactionThreadReport?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID; // When we have an actual transaction thread (childReportID from Onyx) but the report isn't in Onyx yet // (e.g. Search didn't return the IOU action for deleted items), use childReportID directly so we don't navigate with undefined const targetReportID = shouldNavigateToTransactionThread diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index e35c72233149..3dc22d5aa7f0 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -137,6 +137,10 @@ type BuildOptimisticTransactionParams = { isDemoTransactionParam?: boolean; }; +function isDeletedTransaction(transaction: {reportID?: string}): boolean { + return transaction.reportID === CONST.REPORT.TRASH_REPORT_ID; +} + function hasDistanceCustomUnit(transaction: OnyxEntry | Partial): boolean { return transaction?.comment?.type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && transaction?.comment?.customUnit?.name === CONST.CUSTOM_UNITS.NAME_DISTANCE; } @@ -2997,6 +3001,7 @@ export { isDistanceTypeRequest, recalculateUnreportedTransactionDetails, hasSmartScanFailedWithMissingFields, + isDeletedTransaction, }; export type {TransactionChanges}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 5e835767cd9c..6b0e520769b8 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -11884,18 +11884,18 @@ function setMultipleMoneyRequestParticipantsFromReport(transactionIDs: string[], return Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, updatedTransactions); } -const expenseReportStatusFilterMapping = { - [CONST.SEARCH.STATUS.EXPENSE.DRAFTS]: (expenseReport: OnyxEntry) => - expenseReport?.stateNum === CONST.REPORT.STATE_NUM.OPEN && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN, - [CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING]: (expenseReport: OnyxEntry) => +type ExpenseReportStatusPredicate = (expenseReport: OnyxEntry, transactionReportID?: string) => boolean; + +const expenseReportStatusFilterMapping: Record = { + [CONST.SEARCH.STATUS.EXPENSE.DRAFTS]: (expenseReport) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.OPEN && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.OPEN, + [CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING]: (expenseReport) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED, - [CONST.SEARCH.STATUS.EXPENSE.APPROVED]: (expenseReport: OnyxEntry) => - expenseReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.APPROVED, - [CONST.SEARCH.STATUS.EXPENSE.PAID]: (expenseReport: OnyxEntry) => + [CONST.SEARCH.STATUS.EXPENSE.APPROVED]: (expenseReport) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.APPROVED, + [CONST.SEARCH.STATUS.EXPENSE.PAID]: (expenseReport) => (expenseReport?.stateNum ?? 0) >= CONST.REPORT.STATE_NUM.APPROVED && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED, - [CONST.SEARCH.STATUS.EXPENSE.DONE]: (expenseReport: OnyxEntry) => - expenseReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED, - [CONST.SEARCH.STATUS.EXPENSE.UNREPORTED]: (expenseReport: OnyxEntry) => !expenseReport, + [CONST.SEARCH.STATUS.EXPENSE.DONE]: (expenseReport) => expenseReport?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && expenseReport?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED, + [CONST.SEARCH.STATUS.EXPENSE.UNREPORTED]: (expenseReport, transactionReportID) => !expenseReport && transactionReportID !== CONST.REPORT.TRASH_REPORT_ID, + [CONST.SEARCH.STATUS.EXPENSE.DELETED]: (_expenseReport, transactionReportID) => transactionReportID === CONST.REPORT.TRASH_REPORT_ID, [CONST.SEARCH.STATUS.EXPENSE.ALL]: () => true, }; @@ -11915,14 +11915,15 @@ function shouldOptimisticallyUpdateSearch( } let shouldOptimisticallyUpdateByStatus; const status = currentSearchQueryJSON.status; + const transactionReportID = transaction?.reportID; if (Array.isArray(status)) { shouldOptimisticallyUpdateByStatus = status.some((val) => { const expenseStatus = val as ValueOf; - return expenseReportStatusFilterMapping[expenseStatus](iouReport); + return expenseReportStatusFilterMapping[expenseStatus](iouReport, transactionReportID); }); } else { const expenseStatus = status as ValueOf; - shouldOptimisticallyUpdateByStatus = expenseReportStatusFilterMapping[expenseStatus](iouReport); + shouldOptimisticallyUpdateByStatus = expenseReportStatusFilterMapping[expenseStatus](iouReport, transactionReportID); } if (currentSearchQueryJSON.policyID?.length && iouReport?.policyID) { diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index cea6b6356ba5..11b4e1b8a46c 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -108,6 +108,7 @@ type HandleActionButtonPressParams = { isDelegateAccessRestricted?: boolean; onDelegateAccessRestricted?: () => void; personalPolicyID: string | undefined; + onUndelete?: () => void; }; type BulkDeleteReportsParams = { @@ -138,13 +139,14 @@ function handleActionButtonPress({ isDelegateAccessRestricted, onDelegateAccessRestricted, personalPolicyID, + onUndelete, }: HandleActionButtonPressParams) { // The transactionIDList is needed to handle actions taken on `status:""` where transactions on single expense reports can be approved/paid. // We need the transactionID to display the loading indicator for that list item's action. const allReportTransactions = (isTransactionGroupListItemType(item) ? item.transactions : [item]) as Transaction[]; const hasHeldExpense = hasHeldExpenses('', allReportTransactions); - if (hasHeldExpense && item.action !== CONST.SEARCH.ACTION_TYPES.SUBMIT) { + if (hasHeldExpense && item.action !== CONST.SEARCH.ACTION_TYPES.SUBMIT && item.action !== CONST.SEARCH.ACTION_TYPES.UNDELETE) { goToItem(); return; } @@ -199,6 +201,9 @@ function handleActionButtonPress({ exportToIntegrationOnSearch(hash, [item.reportID], connectedIntegration, currentSearchKey); return; } + case CONST.SEARCH.ACTION_TYPES.UNDELETE: + onUndelete?.(); + return; default: goToItem(); } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index f3bfc8a02f6b..1c444f985e71 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -36,6 +36,7 @@ import { buildOptimisticUnreportedTransactionAction, buildTransactionThread, findSelfDMReportID, + getIOUReportActionMessage, getReportTransactions, getTransactionDetails, hasViolations as hasViolationsReportUtils, @@ -43,6 +44,7 @@ import { } from '@libs/ReportUtils'; import { hasPendingRTERViolation, + isDeletedTransaction, isManagedCardTransaction, isOnHold, recalculateUnreportedTransactionDetails, @@ -1012,11 +1014,14 @@ function changeTransactionsReport({ const destinationCurrency = newReport?.currency ?? policy?.outputCurrency; for (const transaction of transactions) { + const isDeletedExpense = isDeletedTransaction(transaction); const isUnreportedExpense = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - const selfDMReportID = existingSelfDMReportID ?? selfDMReport?.reportID; - const oldIOUAction = getIOUActionForReportID(isUnreportedExpense ? selfDMReportID : transaction.reportID, transaction.transactionID); + // Skip lookup for deleted transactions: the old IOU action is already cleaned up + // during deletion and its transaction thread is deleted, so reusing it is harmful. + const oldIOUAction = isDeletedExpense ? undefined : getIOUActionForReportID(isUnreportedExpense ? selfDMReportID : transaction.reportID, transaction.transactionID); + if (!transaction.reportID || transaction.reportID === reportID) { continue; } @@ -1032,29 +1037,36 @@ function changeTransactionsReport({ const optimisticMoneyRequestReportActionID = rand64(); const originalMessage = getOriginalMessage(oldIOUAction) as OriginalMessageIOU; + const actionType = isUnreported ? CONST.IOU.REPORT_ACTION_TYPE.TRACK : CONST.IOU.REPORT_ACTION_TYPE.CREATE; const newIOUAction = { ...oldIOUAction, originalMessage: { ...originalMessage, + IOUTransactionID: originalMessage?.IOUTransactionID ?? transaction.transactionID, IOUReportID: reportID, - type: isUnreported ? CONST.IOU.REPORT_ACTION_TYPE.TRACK : CONST.IOU.REPORT_ACTION_TYPE.CREATE, + type: actionType, }, reportActionID: optimisticMoneyRequestReportActionID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - actionName: oldIOUAction?.actionName ?? CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + actionName: oldIOUAction?.actionName ?? CONST.REPORT.ACTIONS.TYPE.IOU, created: oldIOUAction?.created ?? DateUtils.getDBTime(), + ...(!oldIOUAction && { + message: getIOUReportActionMessage(reportID, actionType, Math.abs(transaction.amount), transaction.comment?.comment ?? '', transaction.currency), + }), }; const {comment, modifiedAmount, modifiedCurrency, modifiedMerchant} = isUnreported ? recalculateUnreportedTransactionDetails(transaction, destinationCurrency, translate, toLocaleDigit) : {}; - // 1. Optimistically change the reportID on the passed transactions - // Only set pendingAction for transactions that need convertedAmount recalculation + // 1. Optimistically update the transaction with full data and changed fields. + // Spreading the full transaction ensures the TRANSACTION collection has complete data + // (e.g. amount) even when the existing entry was incomplete from search results. optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { + ...transaction, reportID, comment, modifiedAmount, @@ -1231,15 +1243,15 @@ function changeTransactionsReport({ // 4. Optimistically update the IOU action reportID const trackExpenseActionableWhisper = isUnreportedExpense ? getTrackExpenseActionableWhisper(transaction.transactionID, selfDMReportID) : undefined; - if (oldIOUAction) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetReportID}`, - value: { - [newIOUAction.reportActionID]: newIOUAction, - }, - }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetReportID}`, + value: { + [newIOUAction.reportActionID]: newIOUAction, + }, + }); + if (oldIOUAction) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${isUnreportedExpense ? selfDMReportID : oldReportID}`, @@ -1272,24 +1284,22 @@ function changeTransactionsReport({ [newIOUAction.reportActionID]: {pendingAction: null}, }, }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetReportID}`, + value: { + [newIOUAction.reportActionID]: null, + }, + }); if (oldIOUAction) { - failureData.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetReportID}`, - value: { - [newIOUAction.reportActionID]: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${isUnreportedExpense ? selfDMReportID : oldReportID}`, - value: { - [oldIOUAction.reportActionID]: oldIOUAction, - ...(trackExpenseActionableWhisper ? {[trackExpenseActionableWhisper.reportActionID]: trackExpenseActionableWhisper} : {}), - }, + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${isUnreportedExpense ? selfDMReportID : oldReportID}`, + value: { + [oldIOUAction.reportActionID]: oldIOUAction, + ...(trackExpenseActionableWhisper ? {[trackExpenseActionableWhisper.reportActionID]: trackExpenseActionableWhisper} : {}), }, - ); + }); } const shouldRemoveOtherParticipants = !isManagedCardTransaction(transaction); @@ -1410,7 +1420,7 @@ function changeTransactionsReport({ const baseTransactionData = { movedReportActionID: movedAction.reportActionID, moneyRequestPreviewReportActionID: newIOUAction.reportActionID, - ...(oldIOUAction && !oldIOUAction.childReportID + ...(transactionThreadCreatedReportActionID ? { transactionThreadReportID, transactionThreadCreatedReportActionID, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 668ea7fc15fa..3a3aa0c5511a 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -193,6 +193,10 @@ const darkTheme = { backgroundColor: colors.pink700, textColor: colors.pink200, }, + deleted: { + backgroundColor: colors.tangerine700, + textColor: colors.productDark900, + }, }, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 87000a8f5114..407d087e26ca 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -193,6 +193,10 @@ const lightTheme = { backgroundColor: colors.pink200, textColor: colors.pink700, }, + deleted: { + backgroundColor: colors.tangerine500, + textColor: colors.productLight100, + }, }, statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index b635cd6730a0..8ab7ccf191e5 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -119,7 +119,7 @@ type ThemeColors = { trialTimer: Color; reportStatusBadge: Record< - 'draft' | 'outstanding' | 'paid' | 'approved' | 'closed', + 'draft' | 'outstanding' | 'paid' | 'approved' | 'closed' | 'deleted', { backgroundColor: Color; textColor: Color; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index b95adf5dba5c..1f46b97d9db2 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -9481,6 +9481,21 @@ describe('ReportUtils', () => { expect(getReportStatusTranslation({stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: undefined, translate: mockTranslate})).toBe(''); expect(getReportStatusTranslation({stateNum: undefined, statusNum: CONST.REPORT.STATUS_NUM.OPEN, translate: mockTranslate})).toBe(''); }); + + it('should return "Deleted" when isDeleted is true', () => { + const result = getReportStatusTranslation({stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, isDeleted: true, translate: mockTranslate}); + expect(result).toBe(mockTranslate('iou.deleted')); + }); + + it('should return "Deleted" when isDeleted is true regardless of stateNum and statusNum', () => { + expect(getReportStatusTranslation({stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, isDeleted: true, translate: mockTranslate})).toBe( + mockTranslate('iou.deleted'), + ); + expect(getReportStatusTranslation({stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, isDeleted: true, translate: mockTranslate})).toBe( + mockTranslate('iou.deleted'), + ); + expect(getReportStatusTranslation({stateNum: undefined, statusNum: undefined, isDeleted: true, translate: mockTranslate})).toBe(mockTranslate('iou.deleted')); + }); }); describe('buildOptimisticReportPreview', () => { diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 5233374c7bc6..f371b061681c 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -7291,6 +7291,7 @@ describe('SearchUIUtils', () => { }); test('Should fallback to childReportID from IOU action when transaction thread report is not in Onyx', async () => { + (createTransactionThreadReport as jest.Mock).mockReset(); const childReportID = 'child-thread-456'; // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style const multiTransactionItem = transactionsListItems.at(2) as TransactionListItemType;