diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b44157276325..ee1773241744 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7152,6 +7152,11 @@ const CONST = { reportView: true, }, TAG: {column: this.TABLE_COLUMNS.TAG, search: true, reportView: true}, + VIOLATIONS: { + column: this.TABLE_COLUMNS.VIOLATIONS, + search: true, + reportView: false, + }, TAG_GL_CODE: { column: this.TABLE_COLUMNS.TAG_GL_CODE, search: true, @@ -7471,6 +7476,7 @@ const CONST = { TO: 'to', CATEGORY: 'category', TAG: 'tag', + VIOLATIONS: 'violations', ORIGINAL_AMOUNT: 'originalamount', REIMBURSABLE: 'reimbursable', BILLABLE: 'billable', @@ -7712,6 +7718,7 @@ const CONST = { [this.TABLE_COLUMNS.TO]: 'to', [this.TABLE_COLUMNS.CATEGORY]: 'category', [this.TABLE_COLUMNS.TAG]: 'tag', + [this.TABLE_COLUMNS.VIOLATIONS]: 'violations', [this.TABLE_COLUMNS.ORIGINAL_AMOUNT]: 'purchase-amount', [this.TABLE_COLUMNS.REIMBURSABLE]: 'reimbursable', [this.TABLE_COLUMNS.BILLABLE]: 'billable', @@ -7806,6 +7813,7 @@ const CONST = { TOP_CATEGORIES: 'topCategories', TOP_MERCHANTS: 'topMerchants', SPEND_OVER_TIME: 'spendOverTime', + VIOLATIONS_BY_SUBMITTER: 'violationsBySubmitter', }, SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index b68c6466e5e3..b7c80a2300ae 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -336,7 +336,7 @@ function TransactionGroupListExpandedImpl({ )} {visibleTransactions.map((transaction, index) => { const shouldShowBottomBorder = !isLastTransaction(index); - const exportedReportActions = Object.values(transactionsSnapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`] ?? {}); + const reportActions = Object.values(transactionsSnapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`] ?? {}); const isDeletedOrPendingDelete = isDeletedTransaction(transaction) || isTransactionPendingDelete(transaction); return ( @@ -384,7 +384,7 @@ function TransactionGroupListExpandedImpl({ shouldShowBottomBorder={shouldShowBottomBorder} onArrowRightPress={isDeletedOrPendingDelete ? undefined : (event) => openReportInRHP(transaction, event)} shouldShowArrowRightOnNarrowLayout - reportActions={exportedReportActions} + reportActions={reportActions} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} isActionColumnWide={isActionColumnWide} isHover={hovered} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx index d1fbd1e9ed51..747bf6b8cf12 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx @@ -44,7 +44,7 @@ function TransactionListItemNarrow({ handleActionButtonPress, shouldDisableActionPointerEvents, transactionPreviewData, - exportedReportActions, + reportActions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy, }: TransactionListItemNarrowProps) { @@ -170,7 +170,7 @@ function TransactionListItemNarrow({ onArrowRightPress={isDeletedTransaction ? undefined : (event) => onSelectRow(item, transactionPreviewData, event)} isHover={false} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} - reportActions={exportedReportActions} + reportActions={reportActions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} /> diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index 188488030802..000529caa7dd 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -42,7 +42,7 @@ function TransactionListItemWide({ handleActionButtonPress, shouldDisableActionPointerEvents, transactionPreviewData, - exportedReportActions, + reportActions, policyCategories, policyTagLists, rowPolicy, @@ -211,7 +211,7 @@ function TransactionListItemWide({ onArrowRightPress={isDeletedTransaction ? undefined : (event) => onSelectRow(item, transactionPreviewData, event)} isHover={hovered} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} - reportActions={exportedReportActions} + reportActions={reportActions} isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy} onEditDate={onEditDate} onEditMerchant={onEditMerchant} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx index eaad08b0a54e..b669c2f9cf61 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -122,7 +122,7 @@ function TransactionListItemInner({ const snapshotPolicy = (currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] ?? {}) as Policy; const actionsData = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionItem.reportID}`]; - const exportedReportActions = actionsData ? Object.values(actionsData) : []; + const reportActions = actionsData ? Object.values(actionsData) : []; // Fetch policy categories directly from Onyx since they are not included in the search snapshot const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); @@ -138,7 +138,7 @@ function TransactionListItemInner({ const allViolations: OnyxCollection = {[transactionViolationsKey]: transactionViolationsForRow}; const parentReportActionID = transactionItem?.reportAction?.reportActionID; const [parentReportAction] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(transactionItem.reportID)}`, { - selector: (reportActions: OnyxEntry): OnyxEntry => reportActions?.[`${parentReportActionID}`], + selector: (actions: OnyxEntry): OnyxEntry => actions?.[`${parentReportActionID}`], }); const currentUserDetails = useCurrentUserPersonalDetails(); const chatReportID = snapshotReport?.chatReportID ?? snapshotReport?.parentReportID; @@ -159,7 +159,7 @@ function TransactionListItemInner({ reportID: transactionItem.reportID, itemKey: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`, snapshotData, - snapshotActions: exportedReportActions, + snapshotActions: reportActions, enabled: !!snapshotData, }); const transactionPreviewData: TransactionPreviewData = { @@ -273,7 +273,7 @@ function TransactionListItemInner({ handleActionButtonPress, shouldDisableActionPointerEvents: shouldDisableSearchSubmitPress, transactionPreviewData, - exportedReportActions, + reportActions, policyCategories, policyTagLists, rowPolicy, diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts index 57e823273cb4..e4d366afb82a 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts @@ -27,7 +27,7 @@ type TransactionListItemSharedProps = { handleActionButtonPress: (event?: ModifiedMouseEvent) => void; shouldDisableActionPointerEvents?: boolean; transactionPreviewData: TransactionPreviewData; - exportedReportActions: ReportAction[]; + reportActions: ReportAction[]; policyCategories?: PolicyCategories; policyTagLists?: PolicyTagLists; rowPolicy?: Policy; diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index a8708d1a8c65..1969275c0f7b 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -111,6 +111,11 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [ translationKey: 'common.tag', canEdit: true, }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, + translationKey: 'common.violations', + isColumnSortable: false, + }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, translationKey: 'common.tagGLCode', diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 696c27521921..67f578d34c43 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -26,6 +26,7 @@ import getBase62ReportID from '@libs/getBase62ReportID'; import {isTaxCodeCustomized, getTagGLCode} from '@libs/PolicyUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {getReimbursableTotal, isExpenseReport} from '@libs/ReportUtils'; +import {getSubmittedViolationsForTransaction} from '@libs/SearchUIUtils'; import {getShiftKeyFromEvent} from '@libs/shiftRangeSelection'; import { getAmount, @@ -208,6 +209,15 @@ function TransactionItemRowWide({ /> ); + case CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS: + return ( + + + + ); case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: return ( expensePermittedColumns.includes(column)); columnsToExport = [CONST.SEARCH.TABLE_COLUMNS.TYPE, ...(expenseColumns.length > 0 ? expenseColumns : Object.values(CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE))]; + // Grouped export skips getColumnsToShow(), so inject Violations when the query asks for it + // (e.g. Violations by submitter, which has groupBy but no saved columns). + if (queryHasSubmittedViolationFilter(queryJSON)) { + insertColumnBeforeTotalAmount(columnsToExport, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + } } else { columnsToExport = getColumnsToShow({ currentAccountID: accountID, diff --git a/src/hooks/useSearchShouldCalculateTotals.ts b/src/hooks/useSearchShouldCalculateTotals.ts index bac80b3ac54d..7b805d24bab9 100644 --- a/src/hooks/useSearchShouldCalculateTotals.ts +++ b/src/hooks/useSearchShouldCalculateTotals.ts @@ -46,6 +46,7 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, search CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS, CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME, + CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER, ]; const isSuggestedSearchWithTotals = eligibleSearchKeys.includes(searchKey); diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index ddcc1d55a8a2..80ded05f6a1a 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -41,6 +41,7 @@ const policyMapper = (policy: OnyxEntry): OnyxEntry => achAccount: policy.achAccount, areCategoriesEnabled: policy.areCategoriesEnabled, areWorkflowsEnabled: policy.areWorkflowsEnabled, + areRulesEnabled: policy.areRulesEnabled, }; const currentUserLoginAndAccountIDSelector = (session: OnyxEntry) => ({ @@ -76,6 +77,10 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + // Migrated Control workspaces leave `areRulesEnabled` undefined; Violations by submitter then depends on + // Classic category rules stored on POLICY_CATEGORIES. No selector: mapping this collection would still be + // large, and shallowEqual on the raw references is cheaper than deepEqual of a transformed copy. + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); // A report awaiting the current user's approval makes the "Needs approval" suggested search relevant even when they // are not part of the policy's approval workflow (e.g. an approver chosen manually on a single report). @@ -125,6 +130,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams draftTransactionIDs, isTrackIntentUser: isTrackIntentUser ?? false, hasReportAwaitingApproval, + policyCategories: allPolicyCategories, }), [ currentUserLoginAndAccountID?.email, @@ -139,6 +145,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams draftTransactionIDs, isTrackIntentUser, hasReportAwaitingApproval, + allPolicyCategories, ], ); diff --git a/src/languages/de.ts b/src/languages/de.ts index 1d568200640f..6dd924d2dd40 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -305,6 +305,7 @@ const translations: TranslationDeepObject = { billable: 'Abrechenbar', nonBillable: 'Nicht abrechenbar', tag: 'Tag', + violations: 'Verstöße', receipt: 'Beleg', verified: 'Verifiziert', replace: 'Ersetzen', @@ -9487,6 +9488,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc topSpenders: 'Höchste Ausgaben', topCategories: 'Topkategorien', topMerchants: 'Top-Händler', + violationsBySubmitter: 'Verstöße durch Einreichende', }, }, genericErrorPage: { @@ -10190,6 +10192,53 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `Der Satz gilt erst ab dem ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `Satz gilt nur bis ${endDate}`, cannotMergeDuplicates: 'Sie können Ausgaben nur in Entwürfen oder offenen Berichten zusammenführen. Ziehen Sie den Bericht zurück und versuchen Sie es erneut.', + shortName: { + allTagLevelsRequired: 'Alle Tags erforderlich', + autoReportedRejectedExpense: 'Ausgabe abgelehnt', + billableExpense: 'Verrechenbar nicht mehr gültig', + cashExpenseWithNoReceipt: 'Beleg erforderlich', + categoryOutOfPolicy: 'Kategorie nicht mehr gültig', + companyCardRequired: 'Firmenkarte erforderlich', + conversionSurcharge: 'Umrechnungszuschlag angewendet', + customUnitOutOfPolicy: 'Satz für Arbeitsbereich ungültig', + customUnitRateOutOfDateRange: 'Kurs außerhalb der gültigen Daten', + duplicatedTransaction: 'Möglicher Duplikat', + fieldRequired: 'Berichtsfeld erforderlich', + futureDate: 'Zukünftiges Datum nicht zulässig', + hold: 'Ausgabe angehalten', + inactiveVendor: 'Lieferant nicht mehr gültig', + increasedDistance: 'Entfernung überschreitet Route', + invoiceMarkup: 'Rechnung mit Aufschlag versehen', + itemizedReceiptRequired: 'Aufgeschlüsselter Beleg erforderlich', + maxAge: 'Datum ist älter als das maximale Spesenalter', + missingAttendees: 'Teilnehmende erforderlich', + missingCategory: 'Fehlende Kategorie', + missingComment: 'Beschreibung erforderlich', + missingTag: 'Fehlendes Tag', + modifiedAmount: 'Betrag geändert', + modifiedDate: 'Änderungsdatum', + noRoute: 'Keine gültige Route', + nonExpensiworksExpense: 'Ausgabe außerhalb von Expensiworks', + overAutoApprovalLimit: 'Über dem Auto-Genehmigungslimit', + overCategoryLimit: 'Über Kategorienlimit', + overLimit: 'Über Limit', + overTripLimit: 'Über dem Reisekostenlimit', + perDayLimit: 'Über Tageslimit', + prohibitedExpense: 'Unzulässige Ausgabe', + receiptGeneratedWithAI: 'Mögliche KI-generierte Quittung', + receiptNotSmartScanned: 'Beleg manuell hinzugefügt', + receiptRequired: 'Beleg erforderlich', + rter: 'Kartenzuordnung ausstehend', + smartscanFailed: 'Belegscan fehlgeschlagen', + someTagLevelsRequired: 'Tag erforderlich', + tagOutOfPolicy: 'Tag nicht mehr gültig', + overLimitAttendee: 'Über Personenlimit', + customRules: 'Verstoß gegen benutzerdefinierte Regel', + taxAmountChanged: 'Steuerbetrag geändert', + taxOutOfPolicy: 'Steuersatz nicht mehr gültig', + taxRateChanged: 'Steuersatz geändert', + taxRequired: 'Fehlender Steuersatz', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} ist erforderlich`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 5dd889fb796c..348c63dc428d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -308,6 +308,7 @@ const translations: TranslationDeepObject = { billable: 'Χρεώσιμη', nonBillable: 'Μη χρεώσιμο', tag: 'Ετικέτα', + violations: 'Παραβάσεις', receipt: 'Απόδειξη', verified: 'Επαληθευμένο', replace: 'Αντικατάσταση', @@ -9413,6 +9414,7 @@ ${reportName}`, topSpenders: 'Κορυφαίοι δαπανώντες', topCategories: 'Κορυφαίες κατηγορίες', topMerchants: 'Κορυφαίοι έμποροι', + violationsBySubmitter: 'Παραβάσεις από τον υποβάλλοντα', }, resultsAreLimited: 'Τα αποτελέσματα αναζήτησης είναι περιορισμένα.', viewResults: 'Προβολή αποτελεσμάτων', @@ -10410,6 +10412,53 @@ ${reportName}`, companyCardRequired: 'Απαιτούνται αγορές με εταιρική κάρτα', noRoute: 'Παρακαλούμε επιλέξτε μια έγκυρη διεύθυνση', cannotMergeDuplicates: 'Μπορείτε να συγχωνεύετε έξοδα μόνο σε πρόχειρες ή εκκρεμείς αναφορές. Ανακαλέστε την και δοκιμάστε ξανά.', + shortName: { + allTagLevelsRequired: 'Απαιτούνται όλες οι ετικέτες', + autoReportedRejectedExpense: 'Η δαπάνη απορρίφθηκε', + billableExpense: 'Η χρέωση δεν ισχύει πλέον', + cashExpenseWithNoReceipt: 'Απαιτείται απόδειξη', + categoryOutOfPolicy: 'Η κατηγορία δεν είναι πλέον έγκυρη', + companyCardRequired: 'Απαιτείται εταιρική κάρτα', + conversionSurcharge: 'Εφαρμόστηκε προσαύξηση μετατροπής', + customUnitOutOfPolicy: 'Μη έγκυρη χρέωση για τον χώρο εργασίας', + customUnitRateOutOfDateRange: 'Χρέωση εκτός έγκυρων ημερομηνιών', + duplicatedTransaction: 'Πιθανό διπλότυπο', + fieldRequired: 'Απαραίτητο πεδίο αναφοράς', + futureDate: 'Δεν επιτρέπεται μελλοντική ημερομηνία', + hold: 'Δαπάνη σε αναμονή', + inactiveVendor: 'Ο προμηθευτής δεν είναι πλέον έγκυρος', + increasedDistance: 'Η απόσταση υπερβαίνει τη διαδρομή', + invoiceMarkup: 'Το τιμολόγιο σημειώθηκε', + itemizedReceiptRequired: 'Απαιτείται αναλυτική απόδειξη', + maxAge: 'Η ημερομηνία είναι παλαιότερη από το μέγιστο επιτρεπτό όριο δαπάνης', + missingAttendees: 'Απαιτούνται συμμετέχοντες', + missingCategory: 'Λείπει κατηγορία', + missingComment: 'Απαιτείται περιγραφή', + missingTag: 'Ελλιπής ετικέτα', + modifiedAmount: 'Το ποσό τροποποιήθηκε', + modifiedDate: 'Ημερομηνία τροποποίησης', + noRoute: 'Μη έγκυρη διαδρομή', + nonExpensiworksExpense: 'Έξοδο εκτός Expensiworks', + overAutoApprovalLimit: 'Πάνω από το όριο αυτόματης έγκρισης', + overCategoryLimit: 'Πέρα από το όριο κατηγορίας', + overLimit: 'Υπέρβαση ορίου', + overTripLimit: 'Υπέρβαση ορίου ταξιδιού', + perDayLimit: 'Υπέρβαση ημερήσιου ορίου', + prohibitedExpense: 'Απαγορευμένη δαπάνη', + receiptGeneratedWithAI: 'Πιθανή απόδειξη που δημιουργήθηκε από AI', + receiptNotSmartScanned: 'Η απόδειξη προστέθηκε χειροκίνητα', + receiptRequired: 'Απαιτείται απόδειξη', + rter: 'Αναμονή αντιστοίχισης κάρτας', + smartscanFailed: 'Η σάρωση απόδειξης απέτυχε', + someTagLevelsRequired: 'Απαιτείται ετικέτα', + tagOutOfPolicy: 'Η ετικέτα δεν είναι πλέον έγκυρη', + overLimitAttendee: 'Υπέρβαση ορίου ατόμων', + customRules: 'Παραβίαση προσαρμοσμένου κανόνα', + taxAmountChanged: 'Το ποσό φόρου τροποποιήθηκε', + taxOutOfPolicy: 'Ο φορολογικός συντελεστής δεν είναι πλέον έγκυρος', + taxRateChanged: 'Ο φορολογικός συντελεστής τροποποιήθηκε', + taxRequired: 'Λείπει ο φορολογικός συντελεστής', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `Απαιτείται το πεδίο ${fieldName}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 1df6f0a7561f..1b3d86999630 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -341,6 +341,7 @@ const translations = { billable: 'Billable', nonBillable: 'Non-billable', tag: 'Tag', + violations: 'Violations', receipt: 'Receipt', verified: 'Verified', replace: 'Replace', @@ -9337,6 +9338,7 @@ const translations = { topSpenders: 'Top spenders', topCategories: 'Top categories', topMerchants: 'Top merchants', + violationsBySubmitter: 'Violations by submitter', }, resultsAreLimited: 'Search results are limited.', viewResults: 'View results', @@ -10336,6 +10338,57 @@ const translations = { resolvedDuplicates: 'resolved the duplicate', companyCardRequired: 'Company card purchases required', noRoute: 'Please select a valid address', + /** + * Parameter-free labels for submitted violations shown in Search table columns. + * Prefer these over sibling `violations.*` keys when violation data/context is unavailable. + */ + shortName: { + allTagLevelsRequired: 'All tags required', + autoReportedRejectedExpense: 'Expense rejected', + billableExpense: 'Billable no longer valid', + cashExpenseWithNoReceipt: 'Receipt required', + categoryOutOfPolicy: 'Category no longer valid', + companyCardRequired: 'Company card required', + conversionSurcharge: 'Conversion surcharge applied', + customUnitOutOfPolicy: 'Rate not valid for workspace', + customUnitRateOutOfDateRange: 'Rate outside valid dates', + duplicatedTransaction: 'Potential duplicate', + fieldRequired: 'Report field required', + futureDate: 'Future date not allowed', + hold: 'Expense on hold', + inactiveVendor: 'Vendor no longer valid', + increasedDistance: 'Distance exceeds route', + invoiceMarkup: 'Invoice marked up', + itemizedReceiptRequired: 'Itemized receipt required', + maxAge: 'Date older than max expense age', + missingAttendees: 'Attendees required', + missingCategory: 'Missing category', + missingComment: 'Description required', + missingTag: 'Missing tag', + modifiedAmount: 'Amount modified', + modifiedDate: 'Date modified', + noRoute: 'No valid route', + nonExpensiworksExpense: 'Non-Expensiworks expense', + overAutoApprovalLimit: 'Over auto-approval limit', + overCategoryLimit: 'Over category limit', + overLimit: 'Over limit', + overTripLimit: 'Over trip limit', + perDayLimit: 'Over daily limit', + prohibitedExpense: 'Prohibited expense', + receiptGeneratedWithAI: 'Possible AI-generated receipt', + receiptNotSmartScanned: 'Receipt added manually', + receiptRequired: 'Receipt required', + rter: 'Awaiting card match', + smartscanFailed: 'Receipt scanning failed', + someTagLevelsRequired: 'Tag required', + tagOutOfPolicy: 'Tag no longer valid', + overLimitAttendee: 'Over person limit', + customRules: 'Custom rule violation', + taxAmountChanged: 'Tax amount modified', + taxOutOfPolicy: 'Tax rate no longer valid', + taxRateChanged: 'Tax rate modified', + taxRequired: 'Missing tax rate', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} is required`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 094ebea708c1..e7af69bf8ec8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -296,6 +296,7 @@ const translations: TranslationDeepObject = { billable: 'Facturable', nonBillable: 'No facturable', tag: 'Etiqueta', + violations: 'Infracciones', receipt: 'Recibo', verified: 'Verificado', replace: 'Sustituir', @@ -9064,6 +9065,7 @@ ${reportName}`, topSpenders: 'Mayores gastadores', topCategories: 'Categorías principales', topMerchants: 'Principales comerciantes', + violationsBySubmitter: 'Infracciones por remitente', }, resultsAreLimited: 'Los resultados de búsqueda están limitados.', viewResults: 'Ver resultados', @@ -10397,6 +10399,53 @@ ${reportName}`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `La tasa solo es válida desde ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `La tasa solo es válida hasta ${endDate}`, cannotMergeDuplicates: 'Solo puedes combinar gastos en informes en borrador o pendientes. Retíralo e inténtalo de nuevo.', + shortName: { + allTagLevelsRequired: 'Todas las etiquetas obligatorias', + autoReportedRejectedExpense: 'Gasto rechazado', + billableExpense: 'Refacturable ya no válido', + cashExpenseWithNoReceipt: 'Recibo obligatorio', + categoryOutOfPolicy: 'La categoría ya no es válida', + companyCardRequired: 'Tarjeta de empresa obligatoria', + conversionSurcharge: 'Suplemento por conversión aplicado', + customUnitOutOfPolicy: 'La tasa no es válida para el espacio de trabajo', + customUnitRateOutOfDateRange: 'Tasa fuera de las fechas válidas', + duplicatedTransaction: 'Posible duplicado', + fieldRequired: 'Campo de informe obligatorio', + futureDate: 'No se permite una fecha futura', + hold: 'Gasto retenido', + inactiveVendor: 'El proveedor ya no es válido', + increasedDistance: 'La distancia supera la ruta', + invoiceMarkup: 'Factura con recargo', + itemizedReceiptRequired: 'Se requiere recibo desglosado', + maxAge: 'Fecha anterior a la antigüedad máxima del gasto', + missingAttendees: 'Asistentes obligatorios', + missingCategory: 'Categoría faltante', + missingComment: 'Descripción obligatoria', + missingTag: 'Etiqueta faltante', + modifiedAmount: 'Importe modificado', + modifiedDate: 'Fecha de modificación', + noRoute: 'Ruta no válida', + nonExpensiworksExpense: 'Gasto ajeno a Expensiworks', + overAutoApprovalLimit: 'Límite de aprobación automática superado', + overCategoryLimit: 'Límite de categoría superado', + overLimit: 'Límite excedido', + overTripLimit: 'Límite de viaje superado', + perDayLimit: 'Límite diario superado', + prohibitedExpense: 'Gasto prohibido', + receiptGeneratedWithAI: 'Posible recibo generado por IA', + receiptNotSmartScanned: 'Recibo añadido manualmente', + receiptRequired: 'Recibo obligatorio', + rter: 'En espera de conciliación de tarjeta', + smartscanFailed: 'Error al escanear el recibo', + someTagLevelsRequired: 'Etiqueta obligatoria', + tagOutOfPolicy: 'Etiqueta ya no válida', + overLimitAttendee: 'Límite de personas superado', + customRules: 'Incumplimiento de regla personalizada', + taxAmountChanged: 'Importe de impuesto modificado', + taxOutOfPolicy: 'La tasa de impuestos ya no es válida', + taxRateChanged: 'Tasa de impuesto modificada', + taxRequired: 'Falta la tasa de impuesto', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName) => `${fieldName} es obligatorio`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 09d758f6fc55..9b90c078e66b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -305,6 +305,7 @@ const translations: TranslationDeepObject = { billable: 'Facturable', nonBillable: 'Non refacturable', tag: 'Tag', + violations: 'Infractions', receipt: 'Reçu', verified: 'Vérifié', replace: 'Remplacer', @@ -9516,6 +9517,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e topSpenders: 'Plus gros dépensiers', topCategories: 'Catégories principales', topMerchants: 'Meilleurs commerçants', + violationsBySubmitter: 'Infractions par déclarant', }, }, genericErrorPage: { @@ -10220,6 +10222,53 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `Le taux n’est valable qu’à partir du ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `Le taux n’est valable que jusqu’au ${endDate}`, cannotMergeDuplicates: 'Vous ne pouvez fusionner des dépenses que sur des notes de frais à l’état de brouillon ou en circulation. Retirez-la puis réessayez.', + shortName: { + allTagLevelsRequired: 'Tous les tags sont obligatoires', + autoReportedRejectedExpense: 'Dépense rejetée', + billableExpense: 'Facturable n’est plus valide', + cashExpenseWithNoReceipt: 'Reçu obligatoire', + categoryOutOfPolicy: 'Catégorie n’est plus valide', + companyCardRequired: 'Carte d’entreprise requise', + conversionSurcharge: 'Surcharge de conversion appliquée', + customUnitOutOfPolicy: 'Taux non valide pour l’espace de travail', + customUnitRateOutOfDateRange: 'Taux en dehors des dates valides', + duplicatedTransaction: 'Doublon potentiel', + fieldRequired: 'Champ de note de frais obligatoire', + futureDate: 'Date future non autorisée', + hold: 'Dépense en attente', + inactiveVendor: 'Fournisseur n’est plus valide', + increasedDistance: 'La distance dépasse l’itinéraire', + invoiceMarkup: 'Facture annotée', + itemizedReceiptRequired: 'Reçu détaillé requis', + maxAge: 'Date antérieure à l’ancienneté maximale de la dépense', + missingAttendees: 'Participants obligatoires', + missingCategory: 'Catégorie manquante', + missingComment: 'Description obligatoire', + missingTag: 'Tag manquant', + modifiedAmount: 'Montant modifié', + modifiedDate: 'Date de modification', + noRoute: 'Aucun itinéraire valide', + nonExpensiworksExpense: 'Dépense hors Expensiworks', + overAutoApprovalLimit: 'Au-delà de la limite d’auto-approbation', + overCategoryLimit: 'Limite de catégorie dépassée', + overLimit: 'Au-delà de la limite', + overTripLimit: 'Au-delà de la limite de voyage', + perDayLimit: 'Au-dessus de la limite quotidienne', + prohibitedExpense: 'Dépense interdite', + receiptGeneratedWithAI: 'Reçu potentiellement généré par IA', + receiptNotSmartScanned: 'Reçu ajouté manuellement', + receiptRequired: 'Reçu obligatoire', + rter: 'En attente de correspondance de carte', + smartscanFailed: 'L’analyse du reçu a échoué', + someTagLevelsRequired: 'Tag obligatoire', + tagOutOfPolicy: 'Tag n’est plus valide', + overLimitAttendee: 'Nombre de personnes dépassé', + customRules: 'Violation de règle personnalisée', + taxAmountChanged: 'Montant de la taxe modifié', + taxOutOfPolicy: 'Taux de taxe n’est plus valide', + taxRateChanged: 'Taux d’imposition modifié', + taxRequired: 'Taux de taxe manquant', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} est obligatoire`, diff --git a/src/languages/it.ts b/src/languages/it.ts index f204e1e3f787..049211525151 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -305,6 +305,7 @@ const translations: TranslationDeepObject = { billable: 'Fatturabile', nonBillable: 'Non fatturabile', tag: 'Etichetta', + violations: 'Violazioni', receipt: 'Ricevuta', verified: 'Verificato', replace: 'Sostituisci', @@ -9450,6 +9451,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, topSpenders: 'Maggiori spendaccioni', topCategories: 'Categorie principali', topMerchants: 'Principali esercenti', + violationsBySubmitter: 'Violazioni da parte dell’autore dell’invio', }, }, genericErrorPage: { @@ -10155,6 +10157,53 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `La tariffa è valida solo a partire dal ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `La tariffa è valida solo fino al ${endDate}`, cannotMergeDuplicates: 'Puoi unire le spese solo nei report in bozza o in sospeso. Revoca il report e riprova.', + shortName: { + allTagLevelsRequired: 'Tutti i tag obbligatori', + autoReportedRejectedExpense: 'Spesa rifiutata', + billableExpense: 'Fatturabile non più valido', + cashExpenseWithNoReceipt: 'Ricevuta obbligatoria', + categoryOutOfPolicy: 'Categoria non più valida', + companyCardRequired: 'Carta aziendale richiesta', + conversionSurcharge: 'Sovrapprezzo di conversione applicato', + customUnitOutOfPolicy: 'Tariffa non valida per lo spazio di lavoro', + customUnitRateOutOfDateRange: 'Tariffa fuori dalle date valide', + duplicatedTransaction: 'Possibile duplicato', + fieldRequired: 'Campo del report obbligatorio', + futureDate: 'Data futura non consentita', + hold: 'Spesa in sospeso', + inactiveVendor: 'Fornitore non più valido', + increasedDistance: 'La distanza supera il percorso', + invoiceMarkup: 'Fattura maggiorata', + itemizedReceiptRequired: 'Ricevuta dettagliata obbligatoria', + maxAge: 'Data precedente al limite massimo per le spese', + missingAttendees: 'Partecipanti obbligatori', + missingCategory: 'Categoria mancante', + missingComment: 'Descrizione obbligatoria', + missingTag: 'Tag mancante', + modifiedAmount: 'Importo modificato', + modifiedDate: 'Data modifica', + noRoute: 'Nessun percorso valido', + nonExpensiworksExpense: 'Spesa non Expensiworks', + overAutoApprovalLimit: 'Oltre il limite di approvazione automatica', + overCategoryLimit: 'Oltre il limite di categoria', + overLimit: 'Oltre il limite', + overTripLimit: 'Oltre il limite del viaggio', + perDayLimit: 'Supera il limite giornaliero', + prohibitedExpense: 'Spesa vietata', + receiptGeneratedWithAI: "Possibile ricevuta generata dall'IA", + receiptNotSmartScanned: 'Ricevuta aggiunta manualmente', + receiptRequired: 'Ricevuta obbligatoria', + rter: 'In attesa di abbinamento carta', + smartscanFailed: 'Scansione della ricevuta non riuscita', + someTagLevelsRequired: 'Tag obbligatorio', + tagOutOfPolicy: 'Tag non più valido', + overLimitAttendee: 'Oltre il limite di persone', + customRules: 'Violazione regola personalizzata', + taxAmountChanged: 'Importo imposta modificato', + taxOutOfPolicy: 'Aliquota fiscale non più valida', + taxRateChanged: 'Aliquota fiscale modificata', + taxRequired: 'Aliquota fiscale mancante', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} è obbligatorio`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6f919de785a2..8b6131bdca34 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -305,6 +305,7 @@ const translations: TranslationDeepObject = { billable: '請求可能', nonBillable: '請求不可', tag: 'タグ', + violations: '違反事項', receipt: 'レシート', verified: '確認済み', replace: '置換', @@ -9313,6 +9314,7 @@ ${reportName}`, topSpenders: '上位の支出者', topCategories: '上位カテゴリ', topMerchants: '上位加盟店', + violationsBySubmitter: '申請者による違反', }, }, genericErrorPage: { @@ -10009,6 +10011,53 @@ ${reportName}`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `料金は${startDate}からのみ有効です`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `料金は${endDate}までのみ有効です`, cannotMergeDuplicates: '経費を統合できるのは、下書きまたは未清算のレポートのみです。レポートを取り下げて、もう一度お試しください。', + shortName: { + allTagLevelsRequired: 'すべてのタグが必須です', + autoReportedRejectedExpense: '経費が却下されました', + billableExpense: '請求可能区分は無効になりました', + cashExpenseWithNoReceipt: '領収書が必要です', + categoryOutOfPolicy: 'カテゴリは無効になっています', + companyCardRequired: '会社カードが必要です', + conversionSurcharge: '両替手数料が適用されました', + customUnitOutOfPolicy: 'ワークスペースに有効なレートではありません', + customUnitRateOutOfDateRange: '有効な日付期間外のレートです', + duplicatedTransaction: '重複の可能性', + fieldRequired: 'レポートの必須項目です', + futureDate: '未来の日付は使用できません', + hold: '保留中の経費', + inactiveVendor: '取引先は有効ではありません', + increasedDistance: '距離がルートを超えています', + invoiceMarkup: '請求書にマークアップを適用しました', + itemizedReceiptRequired: '明細付き領収書が必要です', + maxAge: '経費の最長期限を過ぎた日付です', + missingAttendees: '参加者は必須です', + missingCategory: 'カテゴリが未選択です', + missingComment: '説明が必要です', + missingTag: 'タグがありません', + modifiedAmount: '金額を変更しました', + modifiedDate: '更新日', + noRoute: '有効なルートがありません', + nonExpensiworksExpense: 'Expensiworks 以外の経費', + overAutoApprovalLimit: '自動承認限度額を超過', + overCategoryLimit: 'カテゴリ上限を超えています', + overLimit: '上限超過', + overTripLimit: '出張上限超過', + perDayLimit: '1 日あたりの上限超過', + prohibitedExpense: '禁止経費', + receiptGeneratedWithAI: 'AI が生成した可能性のあるレシート', + receiptNotSmartScanned: '手動でレシートを追加しました', + receiptRequired: '領収書が必要です', + rter: 'カードとの照合待ち', + smartscanFailed: 'レシートのスキャンに失敗しました', + someTagLevelsRequired: 'タグが必須です', + tagOutOfPolicy: 'タグは無効です', + overLimitAttendee: '人数上限超過', + customRules: 'カスタムルール違反', + taxAmountChanged: '税額が変更されました', + taxOutOfPolicy: '税率が無効になりました', + taxRateChanged: '税率を変更しました', + taxRequired: '税率が未設定です', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} は必須です`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f36aec7b386d..a919b81f359f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -305,6 +305,7 @@ const translations: TranslationDeepObject = { billable: 'Factureerbaar', nonBillable: 'Niet-factureerbaar', tag: 'Label', + violations: 'Overtredingen', receipt: 'Bonnetje', verified: 'Geverifieerd', replace: 'Vervangen', @@ -9414,6 +9415,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, topSpenders: 'Grootste uitgaven doeners', topCategories: 'Topcategorieën', topMerchants: 'Topverkopers', + violationsBySubmitter: 'Overtredingen door indiener', }, }, genericErrorPage: { @@ -10117,6 +10119,53 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `Tarief is alleen geldig vanaf ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `Tarief is alleen geldig tot ${endDate}`, cannotMergeDuplicates: 'Je kunt alleen onkosten samenvoegen op concept- of openstaande rapporten. Trek het rapport in en probeer het opnieuw.', + shortName: { + allTagLevelsRequired: 'Alle labels verplicht', + autoReportedRejectedExpense: 'Uitgave afgekeurd', + billableExpense: 'Doorbelastbaar niet langer geldig', + cashExpenseWithNoReceipt: 'Bon nodig', + categoryOutOfPolicy: 'Categorie niet meer geldig', + companyCardRequired: 'Bedrijfskaart vereist', + conversionSurcharge: 'Toeslag voor omrekening toegepast', + customUnitOutOfPolicy: 'Tarief niet geldig voor werkruimte', + customUnitRateOutOfDateRange: 'Tarief buiten geldige datums', + duplicatedTransaction: 'Mogelijke duplicaat', + fieldRequired: 'Rapportveld verplicht', + futureDate: 'Toekomstige datum niet toegestaan', + hold: 'Declaratie in de wacht', + inactiveVendor: 'Leverancier niet meer geldig', + increasedDistance: 'Afstand overschrijdt route', + invoiceMarkup: 'Factuur gecorrigeerd', + itemizedReceiptRequired: 'Gespecificeerde bon vereist', + maxAge: 'Datum ouder dan maximale onkostendatum', + missingAttendees: 'Deelnemers verplicht', + missingCategory: 'Ontbrekende categorie', + missingComment: 'Beschrijving vereist', + missingTag: 'Ontbrekende tag', + modifiedAmount: 'Bedrag aangepast', + modifiedDate: 'Wijzigingsdatum', + noRoute: 'Geen geldige route', + nonExpensiworksExpense: 'Niet-Expensiworks-uitgave', + overAutoApprovalLimit: 'Boven automatische goedkeuringslimiet', + overCategoryLimit: 'Bovencategorie-limiet', + overLimit: 'Bovengrens overschreden', + overTripLimit: 'Bovengrens per rit', + perDayLimit: 'Boven daglimiet', + prohibitedExpense: 'Verboden uitgave', + receiptGeneratedWithAI: 'Mogelijke AI-gegenereerde bon', + receiptNotSmartScanned: 'Bon toegevoegd handmatig', + receiptRequired: 'Bon nodig', + rter: 'Wachten op kaartovereenkomst', + smartscanFailed: 'Bonnetjesscan mislukt', + someTagLevelsRequired: 'Label vereist', + tagOutOfPolicy: 'Tag niet meer geldig', + overLimitAttendee: 'Limiet aantal personen overschreden', + customRules: 'Schending van aangepaste regel', + taxAmountChanged: 'Belastingbedrag gewijzigd', + taxOutOfPolicy: 'Belastingtarief niet meer geldig', + taxRateChanged: 'Belastingtarief gewijzigd', + taxRequired: 'Ontbrekend belastingtarief', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} is verplicht`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e22ac92fbc95..b678fbc601cf 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -309,6 +309,7 @@ const translations: TranslationDeepObject = { billable: 'Fakturowalne', nonBillable: 'Nierozliczalne', tag: 'Tag', + violations: 'Naruszenia', receipt: 'Paragon', verified: 'Zweryfikowano', replace: 'Zastąp', @@ -9439,6 +9440,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, topSpenders: 'Najwięksi wydający', topCategories: 'Najpopularniejsze kategorie', topMerchants: 'Najważniejsi sprzedawcy', + violationsBySubmitter: 'Naruszenia przez zgłaszającego', }, }, genericErrorPage: { @@ -10138,6 +10140,53 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `Stawka jest ważna tylko od ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `Stawka jest ważna tylko do ${endDate}`, cannotMergeDuplicates: 'Możesz łączyć wydatki tylko w szkicach lub otwartych raportach. Wycofaj go i spróbuj ponownie.', + shortName: { + allTagLevelsRequired: 'Wymagane są wszystkie tagi', + autoReportedRejectedExpense: 'Wydatek odrzucony', + billableExpense: 'Opcja rozliczalne nie jest już dostępna', + cashExpenseWithNoReceipt: 'Wymagany paragon', + categoryOutOfPolicy: 'Kategoria już nieaktualna', + companyCardRequired: 'Wymagana karta firmowa', + conversionSurcharge: 'Naliczono opłatę za przewalutowanie', + customUnitOutOfPolicy: 'Stawka nie jest prawidłowa dla tego przestrzeni roboczej', + customUnitRateOutOfDateRange: 'Stawka poza obowiązującymi datami', + duplicatedTransaction: 'Potencjalny duplikat', + fieldRequired: 'Pole raportu jest wymagane', + futureDate: 'Przyszła data jest niedozwolona', + hold: 'Wydatek wstrzymany', + inactiveVendor: 'Dostawca nie jest już prawidłowy', + increasedDistance: 'Dystans przekracza trasę', + invoiceMarkup: 'Faktura oznaczona', + itemizedReceiptRequired: 'Wymagany rachunek z wyszczególnieniem pozycjii', + maxAge: 'Data starsza niż maksymalny wiek wydatku', + missingAttendees: 'Wymagani uczestnicy', + missingCategory: 'Brak kategorii', + missingComment: 'Wymagany opis', + missingTag: 'Brakujący tag', + modifiedAmount: 'Kwota zmodyfikowana', + modifiedDate: 'Data modyfikacji', + noRoute: 'Brak prawidłowej trasy', + nonExpensiworksExpense: 'Wydatek spoza Expensiworks', + overAutoApprovalLimit: 'Powyżej limitu automatycznej akceptacji', + overCategoryLimit: 'Powyżej limitu kategorii', + overLimit: 'Przekroczono limit', + overTripLimit: 'Poza limitem podróży', + perDayLimit: 'Powyżej dziennego limitu', + prohibitedExpense: 'Zabroniony wydatek', + receiptGeneratedWithAI: 'Możliwy paragon wygenerowany przez AI', + receiptNotSmartScanned: 'Paragon dodany ręcznie', + receiptRequired: 'Wymagany paragon', + rter: 'Oczekiwanie na dopasowanie karty', + smartscanFailed: 'Skanowanie paragonu nie powiodło się', + someTagLevelsRequired: 'Tag wymagany', + tagOutOfPolicy: 'Tag nie jest już ważny', + overLimitAttendee: 'Przekroczono limit osób', + customRules: 'Naruszenie reguły niestandardowej', + taxAmountChanged: 'Zmodyfikowano kwotę podatku', + taxOutOfPolicy: 'Stawka podatku nie jest już ważna', + taxRateChanged: 'Zmodyfikowano stawkę podatku', + taxRequired: 'Brak stawki podatku', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `Pole ${fieldName} jest wymagane`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 740e629915f7..7be740845235 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -305,6 +305,7 @@ const translations: TranslationDeepObject = { billable: 'Faturável', nonBillable: 'Não faturável', tag: 'Etiqueta', + violations: 'Violações', receipt: 'Recibo', verified: 'Verificado', replace: 'Substituir', @@ -9418,6 +9419,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, topSpenders: 'Maiores gastadores', topCategories: 'Principais categorias', topMerchants: 'Principais estabelecimentos', + violationsBySubmitter: 'Violações por quem enviou', }, }, genericErrorPage: { @@ -10117,6 +10119,53 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `A tarifa só é válida a partir de ${startDate}`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `A tarifa só é válida até ${endDate}`, cannotMergeDuplicates: 'Você só pode mesclar despesas em relatórios rascunho ou pendentes. Retraia o relatório e tente novamente.', + shortName: { + allTagLevelsRequired: 'Todas as tags obrigatórias', + autoReportedRejectedExpense: 'Despesa rejeitada', + billableExpense: 'Faturável não é mais válido', + cashExpenseWithNoReceipt: 'Recibo obrigatório', + categoryOutOfPolicy: 'Categoria não é mais válida', + companyCardRequired: 'Cartão corporativo obrigatório', + conversionSurcharge: 'Taxa de conversão aplicada', + customUnitOutOfPolicy: 'Tarifa inválida para o espaço de trabalho', + customUnitRateOutOfDateRange: 'Tarifa fora das datas válidas', + duplicatedTransaction: 'Possível duplicado', + fieldRequired: 'Campo de relatório obrigatório', + futureDate: 'Data futura não é permitida', + hold: 'Despesa em espera', + inactiveVendor: 'Fornecedor não é mais válido', + increasedDistance: 'A distância excede a rota', + invoiceMarkup: 'Fatura com acréscimo', + itemizedReceiptRequired: 'É necessário recibo detalhado', + maxAge: 'Data anterior ao limite máximo de idade da despesa', + missingAttendees: 'Participantes obrigatórios', + missingCategory: 'Categoria ausente', + missingComment: 'Descrição obrigatória', + missingTag: 'Tag ausente', + modifiedAmount: 'Valor modificado', + modifiedDate: 'Data de modificação', + noRoute: 'Nenhuma rota válida', + nonExpensiworksExpense: 'Despesa fora do Expensiworks', + overAutoApprovalLimit: 'Acima do limite de aprovação automática', + overCategoryLimit: 'Acima do limite da categoria', + overLimit: 'Acima do limite', + overTripLimit: 'Acima do limite da viagem', + perDayLimit: 'Acima do limite diário', + prohibitedExpense: 'Despesa proibida', + receiptGeneratedWithAI: 'Possível recibo gerado por IA', + receiptNotSmartScanned: 'Recibo adicionado manualmente', + receiptRequired: 'Recibo obrigatório', + rter: 'Aguardando correspondência do cartão', + smartscanFailed: 'Falha ao escanear recibo', + someTagLevelsRequired: 'Tag obrigatória', + tagOutOfPolicy: 'Tag não é mais válida', + overLimitAttendee: 'Acima do limite de pessoas', + customRules: 'Violação de regra personalizada', + taxAmountChanged: 'Valor do imposto modificado', + taxOutOfPolicy: 'Alíquota de imposto não é mais válida', + taxRateChanged: 'Alíquota de imposto modificada', + taxRequired: 'Alíquota de imposto ausente', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} é obrigatório`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index eabb75c5db65..0d5681adf938 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -305,6 +305,7 @@ const translations: TranslationDeepObject = { billable: '可计费', nonBillable: '不可计费', tag: '标签', + violations: '违规', receipt: '收据', verified: '已验证', replace: '替换', @@ -9067,6 +9068,7 @@ ${reportName}`, topSpenders: '最高消费者', topCategories: '热门类别', topMerchants: '热门商家', + violationsBySubmitter: '提交人违规', }, }, genericErrorPage: { @@ -9752,6 +9754,53 @@ ${reportName}`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `费率仅自 ${startDate} 起有效`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `该费率仅在 ${endDate} 之前有效`, cannotMergeDuplicates: '您只能在草稿或未结报销单中合并报销。请先撤回后重试。', + shortName: { + allTagLevelsRequired: '所有标签为必填项', + autoReportedRejectedExpense: '报销已被拒绝', + billableExpense: '可计费已不再有效', + cashExpenseWithNoReceipt: '需要收据', + categoryOutOfPolicy: '类别已失效', + companyCardRequired: '需要公司卡', + conversionSurcharge: '已收取兑换附加费', + customUnitOutOfPolicy: '此费率对该工作区无效', + customUnitRateOutOfDateRange: '费率超出有效日期', + duplicatedTransaction: '可能重复', + fieldRequired: '报表字段为必填项', + futureDate: '不允许未来日期', + hold: '报销已暂挂', + inactiveVendor: '供应商不再有效', + increasedDistance: '距离超过路线', + invoiceMarkup: '发票已标记加价', + itemizedReceiptRequired: '需要项目明细收据', + maxAge: '日期早于报销允许的最长期限', + missingAttendees: '必需参加者', + missingCategory: '缺少类别', + missingComment: '必须填写说明', + missingTag: '缺少标签', + modifiedAmount: '金额已修改', + modifiedDate: '修改日期', + noRoute: '无效路由', + nonExpensiworksExpense: '非 Expensiworks 报销', + overAutoApprovalLimit: '超出自动批准限额', + overCategoryLimit: '超出类别限额', + overLimit: '超出限额', + overTripLimit: '超出行程限额', + perDayLimit: '超出每日限额', + prohibitedExpense: '禁止报销费用', + receiptGeneratedWithAI: '可能由 AI 生成的收据', + receiptNotSmartScanned: '已手动添加收据', + receiptRequired: '需要收据', + rter: '等待卡片匹配', + smartscanFailed: '收据扫描失败', + someTagLevelsRequired: '必须填写标签', + tagOutOfPolicy: '标签已失效', + overLimitAttendee: '超出人数上限', + customRules: '自定义规则违规', + taxAmountChanged: '税额已修改', + taxOutOfPolicy: '税率已失效', + taxRateChanged: '税率已修改', + taxRequired: '缺少税率', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName: string) => `${fieldName} 为必填项`, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 77ceeb96a23a..cd0fbc300569 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -5083,6 +5083,7 @@ export { isHoldAction, isWhisperAction, isSubmittedAction, + isSubmittedAndClosedAction, isDynamicExternalWorkflowSubmitAction, isMarkAsClosedAction, isForwardedAction, diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 4ecce9231e5b..2861fb35b26a 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -457,6 +457,21 @@ function getFilterFromQuery(queryJSON: SearchQueryJSON | undefined, filterKey: S return {value, isNegated}; } +/** + * Whether the query includes a positive `has:submitted-violation` filter. + * Grouped CSV export uses this so Violations is included even when the query has no saved `columns`. + */ +function queryHasSubmittedViolationFilter(queryJSON: SearchQueryJSON | undefined): boolean { + const hasFilterGroups = queryJSON?.flatFilters.filter((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS) ?? []; + if (hasFilterGroups.length === 0) { + return false; + } + + return hasFilterGroups.some((group) => + group.filters.some((filter) => filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && filter.value.toString() === CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION), + ); +} + /** * Resolves a typed workspace name to its ID. Names are not unique, so an ambiguous one is left alone rather than * guessing which workspace was meant. @@ -610,7 +625,7 @@ function getQueryHashes(query: SearchQueryJSON) { // Certain filters' values are significant in deciding which search we are on, so we want to include // their value when computing the similarSearchHash - const similarSearchValueBasedFilters = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION]); + const similarSearchValueBasedFilters = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS]); const flatFilters = query.flatFilters .map((filter) => { @@ -2683,6 +2698,7 @@ export { removeNegation, getFilterFormValues, getFilterFromQuery, + queryHasSubmittedViolationFilter, }; export type {BuildUserReadableQueryStringParams}; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index aa74f37e53fa..0dda3c0c4c5f 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -122,6 +122,7 @@ import Parser from './Parser'; import {getLoginByAccountID, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; import { arePaymentsEnabled, + arePolicyRulesEnabled, canSendInvoice, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, @@ -145,6 +146,8 @@ import { isMoneyRequestAction, isReportActionVisible, isResolvedActionableWhisper, + isSubmittedAction, + isSubmittedAndClosedAction, isWhisperActionTargetedToOthers, } from './ReportActionsUtils'; import {deprecatedGetReportName} from './ReportNameUtils'; @@ -444,6 +447,7 @@ const nonSortableColumns = new Set([ CONST.SEARCH.TABLE_COLUMNS.ACTION, CONST.SEARCH.TABLE_COLUMNS.IN, CONST.SEARCH.TABLE_COLUMNS.AVATAR, + CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, ]); function isValidExpenseStatus(status: unknown): status is ValueOf { @@ -573,6 +577,7 @@ const SEARCH_TYPE_MENU_ICON_NAMES = [ 'CreditCardHourglass', 'Bank', 'User', + 'UserEye', 'Folder', 'Basket', 'CalendarSolid', @@ -1056,6 +1061,38 @@ function getSuggestedSearches( CONST.SEARCH.TOP_SEARCH_LIMIT, CONST.SEARCH.VIEW.PIE, ), + [CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]: { + key: CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER, + translationPath: 'search.tabs.violationsBySubmitter', + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon: 'UserEye', + searchQuery: buildQueryStringFromFilterFormValues( + { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + groupBy: CONST.SEARCH.GROUP_BY.FROM, + submittedOn: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + has: [CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION], + view: CONST.SEARCH.VIEW.TABLE, + limit: String(CONST.SEARCH.TOP_SEARCH_LIMIT), + }, + { + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + }, + ), + get searchQueryJSON() { + return buildSearchQueryJSON(this.searchQuery); + }, + get hash() { + return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID; + }, + get similarSearchHash() { + return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + get recentSearchHash() { + return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + }, [CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]: { key: CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME, translationPath: 'search.spendOverTime', @@ -1107,6 +1144,9 @@ function isPolicyEligibleForSpendOverTime(policy: OnyxTypes.Policy, currentUserE * `hasReportAwaitingApproval` seeds the approve suggestion so a user who is the manager of a report awaiting their * approval sees it even when they are not part of the policy's approval workflow (e.g. an approver chosen manually on * a single report). The workflow-config checks below only cover standing approvers, which is not enough on their own. + * + * @param policyCategories - Needed for migrated Control workspaces where `areRulesEnabled` is undefined; the Violations + * by submitter insight then falls back to Classic category rules via `arePolicyRulesEnabled`. */ function getSuggestedSearchesVisibility( currentUserEmail: string | undefined, @@ -1115,6 +1155,7 @@ function getSuggestedSearchesVisibility( defaultExpensifyCard: CardFeedForDisplay | undefined, hasReportAwaitingApproval = false, isTrackIntentUser = false, + policyCategories?: OnyxCollection, ): {visibility: Record, boolean>; hasEligibleGroupPolicies: boolean; shouldShowExpensifyCard: boolean; topSpendersPolicyIDs: string[]} { let shouldShowSubmitSuggestion = false; let shouldShowPaySuggestion = false; @@ -1128,6 +1169,7 @@ function getSuggestedSearchesVisibility( let shouldShowTopSpendersSuggestion = false; let shouldShowTopCategoriesSuggestion = false; let shouldShowTopMerchantsSuggestion = false; + let shouldShowViolationsBySubmitterSuggestion = false; let hasEligibleGroupPolicies = false; let shouldShowSpendOverTimeSuggestion = false; const topSpendersPolicyIDs: string[] = []; @@ -1175,6 +1217,11 @@ function getSuggestedSearchesVisibility( const isEligibleForTopSpendersSuggestion = isGroupPolicyEligible && (isAdmin || isAuditor || isUserApprover) && memberCount >= 2; const isEligibleForTopCategoriesSuggestion = isGroupPolicyEligible && policy.areCategoriesEnabled === true; const isEligibleForTopMerchantsSuggestion = isGroupPolicyEligible; + const isEligibleForViolationsBySubmitterSuggestion = + isGroupPolicyEligible && + (isAdmin || isAuditor) && + arePolicyRulesEnabled(policy, policy.id ? policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`] : undefined) && + memberCount >= 2; shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion; shouldShowPaySuggestion ||= isEligibleForPaySuggestion; @@ -1191,6 +1238,7 @@ function getSuggestedSearchesVisibility( } shouldShowTopCategoriesSuggestion ||= isEligibleForTopCategoriesSuggestion; shouldShowTopMerchantsSuggestion ||= isEligibleForTopMerchantsSuggestion; + shouldShowViolationsBySubmitterSuggestion ||= isEligibleForViolationsBySubmitterSuggestion; hasEligibleGroupPolicies ||= isGroupPolicyEligible && !policy.isJoinRequestPending && @@ -1214,6 +1262,7 @@ function getSuggestedSearchesVisibility( [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: shouldShowTopSpendersSuggestion && !isTrackIntentWithWorkflowsDisabled, [CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES]: shouldShowTopCategoriesSuggestion, [CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]: shouldShowTopMerchantsSuggestion, + [CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]: shouldShowViolationsBySubmitterSuggestion, [CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]: shouldShowSpendOverTimeSuggestion, }, hasEligibleGroupPolicies, @@ -4602,6 +4651,23 @@ function isSearchResultsEmpty(searchResults: SearchResults, groupBy?: SearchGrou ); } +/** + * Inserts `columnId` immediately before the total-amount column, or appends it if that column is missing. + */ +function insertColumnBeforeTotalAmount(columns: T[], columnId: T) { + if (columns.includes(columnId)) { + return; + } + + const totalAmountIndex = columns.findIndex((column) => column === CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); + if (totalAmountIndex === -1) { + columns.push(columnId); + return; + } + + columns.splice(totalAmountIndex, 0, columnId); +} + function getCustomColumns(value?: SearchDataTypes | SearchGroupBy): SearchCustomColumnIds[] { switch (value) { case CONST.SEARCH.DATA_TYPES.EXPENSE: @@ -4724,6 +4790,8 @@ function getSearchColumnTranslationKey(column: SearchSortBy): TranslationPaths { return 'common.type'; case CONST.SEARCH.TABLE_COLUMNS.TAG: return 'common.tag'; + case CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS: + return 'common.violations'; case CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT: return 'common.purchaseAmount'; case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE: @@ -4938,6 +5006,7 @@ type TypeMenuSectionsParams = { draftTransactionIDs: string[] | undefined; isTrackIntentUser: boolean; hasReportAwaitingApproval?: boolean; + policyCategories?: OnyxCollection; }; function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuSection[] { @@ -4954,6 +5023,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS draftTransactionIDs, isTrackIntentUser, hasReportAwaitingApproval = false, + policyCategories, } = params; const typeMenuSections: SearchTypeMenuSection[] = []; @@ -4962,7 +5032,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS hasEligibleGroupPolicies, shouldShowExpensifyCard, topSpendersPolicyIDs, - } = getSuggestedSearchesVisibility(currentUserEmail, cardFeedsByPolicy, policies, defaultExpensifyCard, hasReportAwaitingApproval, isTrackIntentUser); + } = getSuggestedSearchesVisibility(currentUserEmail, cardFeedsByPolicy, policies, defaultExpensifyCard, hasReportAwaitingApproval, isTrackIntentUser, policyCategories); const suggestedSearches = getSuggestedSearches(currentUserAccountID, defaultCardFeed?.id, shouldShowExpensifyCard, topSpendersPolicyIDs, activeExpensifyCardFeedID); const hasAnyPolicyWithWorkflowsEnabled = Object.values(policies ?? {}).some((policy) => policy?.areWorkflowsEnabled); const isTrackIntentWithWorkflowsDisabled = isTrackIntentUser && !hasAnyPolicyWithWorkflowsEnabled; @@ -5117,6 +5187,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES, CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS, + CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER, ]; for (const key of insightsSearchKeys) { @@ -5277,6 +5348,72 @@ function getHasOptions(translate: LocalizedTranslate, type: SearchDataTypes) { } } +type SubmittedTransactionViolationShortName = ValueOf; + +const SUBMITTED_TRANSACTION_VIOLATION_SHORT_NAME_SET = new Set(Object.values(CONST.VIOLATIONS)); + +function isSubmittedTransactionViolationShortName(name: string): name is SubmittedTransactionViolationShortName { + return SUBMITTED_TRANSACTION_VIOLATION_SHORT_NAME_SET.has(name); +} + +/** + * Returns a parameter-free display label for a submitted violation name. + * Falls back to the raw identifier when no short-name translation exists. + */ +function getSubmittedViolationDisplayName(violationName: string, translate: LocalizedTranslate): string { + return isSubmittedTransactionViolationShortName(violationName) ? translate(`violations.shortName.${violationName}`) : violationName; +} + +/** + * Collects a transaction's submitted violations from its report's submit actions. + * A report can be submitted more than once, so this aggregates across every submit action, + * dedupes by violation name, and returns a comma-separated display string. + * When `translate` is provided, violation identifiers are converted to localized short labels. + * + * Itemized receipt required supersedes receipt required (same rule as `filterReceiptViolations`), + * so both are never shown together for a single expense. + */ +function getSubmittedViolationsForTransaction(reportActions: OnyxTypes.ReportAction[] | undefined, transactionID: string | undefined, translate?: LocalizedTranslate): string | undefined { + if (!reportActions?.length || !transactionID) { + return undefined; + } + + const violationNames = new Set(); + for (const action of reportActions) { + if (!isSubmittedAction(action) && !isSubmittedAndClosedAction(action)) { + continue; + } + + const originalMessage = getOriginalMessage(action); + const transactionViolations = originalMessage?.violations?.transactions?.[transactionID]; + if (!transactionViolations?.length) { + continue; + } + + for (const violation of transactionViolations) { + if (violation.name) { + violationNames.add(violation.name); + } + } + } + + if (violationNames.size === 0) { + return undefined; + } + + // Match ViolationMessages / submitter surfaces: only one of these receipt rules can apply. + if (violationNames.has(CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED)) { + violationNames.delete(CONST.VIOLATIONS.RECEIPT_REQUIRED); + } + + const names = Array.from(violationNames); + if (!translate) { + return names.join(', '); + } + + return names.map((name) => getSubmittedViolationDisplayName(name, translate)).join(', '); +} + function getTypeOptions(translate: LocalizedTranslate, policies: OnyxCollection, currentUserLogin?: string) { const typeOptions: Array> = [ {text: translate('common.expense'), value: CONST.SEARCH.DATA_TYPES.EXPENSE}, @@ -6454,6 +6591,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]: false, [CONST.SEARCH.TABLE_COLUMNS.TAG]: false, [CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE]: false, + [CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS]: false, [CONST.SEARCH.TABLE_COLUMNS.CARD]: false, [CONST.SEARCH.TABLE_COLUMNS.MCC]: false, [CONST.SEARCH.TABLE_COLUMNS.TAX_CODE]: false, @@ -6487,6 +6625,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]: false, [CONST.SEARCH.TABLE_COLUMNS.TAG]: false, [CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE]: false, + [CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS]: false, [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE]: false, [CONST.SEARCH.TABLE_COLUMNS.BILLABLE]: false, [CONST.SEARCH.TABLE_COLUMNS.MCC]: false, @@ -6597,6 +6736,13 @@ function getColumnsToShow({ columns[CONST.SEARCH.TABLE_COLUMNS.TAG] = !isExpenseReportViewFromIOUReport; } + if (!isExpenseReportView && !Array.isArray(data)) { + const reportActions = Object.values(data[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}); + if (getSubmittedViolationsForTransaction(reportActions, transaction.transactionID)) { + columns[CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS] = true; + } + } + // Data-presence checks for columns that are hidden when empty. // Only update when we have custom columns to filter (customResult) or in expense report view, // so that the default search page path doesn't show extra columns. @@ -6712,6 +6858,9 @@ function getColumnsToShow({ } if (customResult) { + if (columns[CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS]) { + insertColumnBeforeTotalAmount(customResult, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + } return customResult; } @@ -6979,6 +7128,7 @@ const FLEX_COLUMNS = new Set([ CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE, CONST.SEARCH.TABLE_COLUMNS.TAG, CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, + CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, CONST.SEARCH.TABLE_COLUMNS.TAX_RATE, CONST.SEARCH.TABLE_COLUMNS.CARD, CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE, @@ -7116,7 +7266,9 @@ export { getWithdrawalStatusOptions, getWithdrawalStatusDisplayText, getColumnsToShow, + insertColumnBeforeTotalAmount, getHasOptions, + getSubmittedViolationsForTransaction, getSettlementStatus, getSettlementStatusBadgeProps, getSearchColumnTranslationKey, diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index eb4f90065b5e..d3f35d34a0e1 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -5,7 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; import {buildQueryStringFromFilterFormValues, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; -import {getCustomColumnDefault, getCustomColumns} from '@libs/SearchUIUtils'; +import {getCustomColumnDefault, getCustomColumns, insertColumnBeforeTotalAmount} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -23,9 +23,10 @@ function SearchColumnsPage() { const allTypeCustomColumns = getCustomColumns(queryType); const allGroupCustomColumns = getCustomColumns(groupBy); const defaultGroupCustomColumns = getCustomColumnDefault(groupBy); - const defaultTypeCustomColumns = getCustomColumnDefault(queryType); + const defaultTypeCustomColumns = [...getCustomColumnDefault(queryType)]; + const shouldRequireViolationsColumn = !!searchAdvancedFiltersForm?.has?.includes(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); - const currentColumns = searchAdvancedFiltersForm?.columns ?? []; + const currentColumns = [...(searchAdvancedFiltersForm?.columns ?? [])]; // We need at least one element with flex1 in the table to ensure the table looks good in the UI, so we don't allow removing the total columns // since it makes sense for them to show up in an expense management App and it fixes the layout issues. @@ -44,6 +45,14 @@ function SearchColumnsPage() { CONST.SEARCH.TABLE_COLUMNS.GROUP_QUARTER, ]); + if (shouldRequireViolationsColumn) { + requiredColumns.add(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + insertColumnBeforeTotalAmount(defaultTypeCustomColumns, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + if (currentColumns.length > 0) { + insertColumnBeforeTotalAmount(currentColumns, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + } + } + const applyChanges = (selectedColumnIds: SearchCustomColumnIds[]) => { const updatedAdvancedFilters: Partial = { ...searchAdvancedFiltersForm, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 489bafa8dfa1..3ebbc9b6c808 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2011,6 +2011,10 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.SEARCH.TABLE_COLUMNS.GROUP_TAG: columnWidth = {...getWidthStyle(variables.w36), ...styles.flex1}; break; + case CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS: + // Wider than category/tag so short violation labels are less likely to truncate. + columnWidth = {...getWidthStyle(variables.w130), ...styles.flex1}; + break; case CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT: columnWidth = { ...getWidthStyle(isTaxAmountColumnWide ? variables.w130 : variables.w96), diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 18c36a7da0fa..2a168a129ed8 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -250,6 +250,12 @@ type OriginalMessagePolicyExpenseChatWelcomeWhisper = { type?: string; }; +/** Model of a violation captured on a submitted report action */ +type SubmittedTransactionViolation = { + /** Violation identifier/name captured at submit time */ + name: string; +}; + /** Model of `submitted` report action */ type OriginalMessageSubmitted = { /** The login of the admin (used in admin-submit) */ @@ -284,6 +290,12 @@ type OriginalMessageSubmitted = { /** The workflow the report is submitted on */ workflow?: ValueOf; + + /** Snapshot of transaction violations present when the report was submitted */ + violations?: { + /** Violations keyed by transaction ID */ + transactions: Record; + }; }; /** Model of `created` report action */ diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 0e23465028f6..fb757dd45cd8 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -24,6 +24,7 @@ import { getFilterDisplayValue, getFilterFormValues, getFilterFromQuery, + queryHasSubmittedViolationFilter, getDateFilterRange, getKeywordQueryWithCurrentSearchContext, getLastRouteByName, @@ -1953,6 +1954,20 @@ describe('SearchQueryUtils', () => { expect(queryJSONa?.similarSearchHash).not.toEqual(queryJSONb?.similarSearchHash); }); + it('should return different similarSearchHash for queries with different has values', () => { + const queryJSONa = buildSearchQueryJSON(`type:expense groupBy:from submitted:last-month has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`); + const queryJSONb = buildSearchQueryJSON(`type:expense groupBy:from submitted:last-month has:${CONST.SEARCH.HAS_VALUES.RECEIPT}`); + + expect(queryJSONa?.similarSearchHash).not.toEqual(queryJSONb?.similarSearchHash); + }); + + it('should return same similarSearchHash for queries with the same has value but different dates', () => { + const queryJSONa = buildSearchQueryJSON(`type:expense groupBy:from submitted:last-month has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`); + const queryJSONb = buildSearchQueryJSON(`type:expense groupBy:from submitted:last-year has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`); + + expect(queryJSONa?.similarSearchHash).toEqual(queryJSONb?.similarSearchHash); + }); + it('should return different primary hash for queries with different explicit views but the same similarSearchHash', () => { const queryJSONa = buildSearchQueryJSON('type:expense groupBy:category view:pie'); const queryJSONb = buildSearchQueryJSON('type:expense groupBy:category view:bar'); @@ -3821,6 +3836,42 @@ describe('SearchQueryUtils', () => { }); }); + describe('queryHasSubmittedViolationFilter', () => { + test('returns true for a positive has:submitted-violation filter', () => { + const queryJSON = buildSearchQueryJSON(`type:expense has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`); + + expect(queryHasSubmittedViolationFilter(queryJSON)).toBe(true); + }); + + test('returns false when the has filter is negated', () => { + const queryJSON = buildSearchQueryJSON(`type:expense -has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`); + + expect(queryHasSubmittedViolationFilter(queryJSON)).toBe(false); + }); + + test('returns false when submitted-violation is negated alongside other positive has filters', () => { + const queryJSON = buildSearchQueryJSON(`type:expense groupBy:from has:${CONST.SEARCH.HAS_VALUES.RECEIPT} -has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`); + + expect(queryHasSubmittedViolationFilter(queryJSON)).toBe(false); + }); + + test('returns true when submitted-violation is positive alongside other has filters', () => { + const queryJSON = buildSearchQueryJSON(`type:expense groupBy:from has:${CONST.SEARCH.HAS_VALUES.RECEIPT} has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`); + + expect(queryHasSubmittedViolationFilter(queryJSON)).toBe(true); + }); + + test('returns false when the query has no submitted-violation filter', () => { + const queryJSON = buildSearchQueryJSON('type:expense groupBy:from'); + + expect(queryHasSubmittedViolationFilter(queryJSON)).toBe(false); + }); + + test('returns false for an undefined queryJSON', () => { + expect(queryHasSubmittedViolationFilter(undefined)).toBe(false); + }); + }); + describe('getAllPolicyValues', () => { const policy1 = createRandomPolicy(1, undefined, 'Workspace 1'); const policy2 = createRandomPolicy(2, undefined, 'Workspace 2'); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 5c9ff637a555..5c87f1cbcdce 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -9938,6 +9938,178 @@ describe('SearchUIUtils', () => { expect(response.visibility.topCategories).toBe(false); }); + test('Should show Violations by submitter for Admin on a Control policy with rules and 2+ members', () => { + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + areRulesEnabled: true, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + 'employee2@policy.com': {email: 'employee2@policy.com'}, + }, + }), + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(true); + }); + + test('Should show Violations by submitter for Auditor on a Control policy with rules and 2+ members', () => { + const auditorEmail = 'auditor@policy.com'; + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.AUDITOR, + areRulesEnabled: true, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + 'employee2@policy.com': {email: 'employee2@policy.com'}, + }, + }), + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(auditorEmail, {}, policies, undefined); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(true); + }); + + test('Should hide Violations by submitter for User role even on a Control policy with rules', () => { + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.USER, + areRulesEnabled: true, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + 'employee2@policy.com': {email: 'employee2@policy.com'}, + }, + }), + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility('user@policy.com', {}, policies, undefined); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(false); + }); + + test('Should hide Violations by submitter when rules are disabled', () => { + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + areRulesEnabled: false, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + 'employee2@policy.com': {email: 'employee2@policy.com'}, + }, + }), + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(false); + }); + + test('Should hide Violations by submitter for Collect/Team policies even when areRulesEnabled is true', () => { + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + areRulesEnabled: true, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + 'employee2@policy.com': {email: 'employee2@policy.com'}, + }, + }), + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(false); + }); + + test('Should hide Violations by submitter for migrated Control workspaces when category data is not supplied', () => { + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + areRulesEnabled: undefined, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + 'employee2@policy.com': {email: 'employee2@policy.com'}, + }, + }), + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(false); + }); + + test('Should show Violations by submitter for migrated Control workspaces with Classic category rules', () => { + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + areRulesEnabled: undefined, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + 'employee2@policy.com': {email: 'employee2@policy.com'}, + }, + }), + }; + const policyCategories: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]: { + Travel: {name: 'Travel', enabled: true, maxExpenseAmount: 50000}, + }, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined, false, false, policyCategories); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(true); + }); + + test('Should hide Violations by submitter for migrated Control workspaces with no category rules', () => { + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + areRulesEnabled: undefined, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + 'employee2@policy.com': {email: 'employee2@policy.com'}, + }, + }), + }; + const policyCategories: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]: { + Travel: {name: 'Travel', enabled: true}, + }, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined, false, false, policyCategories); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(false); + }); + + test('Should hide Violations by submitter when the workspace has fewer than 2 members', () => { + const policies: OnyxCollection = { + [`policy_${policyID}`]: createMock({ + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + areRulesEnabled: true, + employeeList: { + 'employee1@policy.com': {email: 'employee1@policy.com'}, + }, + }), + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + expect(response.visibility[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]).toBe(false); + }); + test('Should show Spend Over Time for Admin role in paid policy', () => { const policyKey = `policy_${policyID}`; @@ -10456,6 +10628,72 @@ describe('SearchUIUtils', () => { expect(topMerchants.searchQueryJSON?.sortBy).toBe(CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL); expect(topMerchants.searchQueryJSON?.sortOrder).toBe(CONST.SEARCH.SORT_ORDER.DESC); }); + + test('Should default Violations by submitter to sortBy groupExpenses and sortOrder desc', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID); + const violationsBySubmitter = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]; + expect(violationsBySubmitter.searchQueryJSON?.sortBy).toBe(CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES); + expect(violationsBySubmitter.searchQueryJSON?.sortOrder).toBe(CONST.SEARCH.SORT_ORDER.DESC); + }); + }); + + describe('Test getSuggestedSearches', () => { + test('Should return Violations by submitter search with correct properties', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined); + const violationsBySubmitterSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]; + + expect(violationsBySubmitterSearch).toBeDefined(); + expect(violationsBySubmitterSearch.key).toBe(CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER); + expect(violationsBySubmitterSearch.translationPath).toBe('search.tabs.violationsBySubmitter'); + expect(violationsBySubmitterSearch.type).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); + expect(violationsBySubmitterSearch.icon).toBe('UserEye'); + }); + + test('Should return Violations by submitter search query with correct parameters', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined); + const violationsBySubmitterSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]; + const searchQueryJSON = violationsBySubmitterSearch.searchQueryJSON; + + expect(searchQueryJSON).toBeDefined(); + expect(searchQueryJSON?.type).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); + expect(searchQueryJSON?.groupBy).toBe(CONST.SEARCH.GROUP_BY.FROM); + expect(searchQueryJSON?.view).toBe(CONST.SEARCH.VIEW.TABLE); + expect(searchQueryJSON?.sortBy).toBe(CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES); + expect(searchQueryJSON?.sortOrder).toBe(CONST.SEARCH.SORT_ORDER.DESC); + + const submittedFilter = searchQueryJSON?.flatFilters?.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED); + expect(submittedFilter).toBeDefined(); + expect(submittedFilter?.filters?.some((filter) => filter.value === CONST.SEARCH.DATE_PRESETS.LAST_MONTH)).toBe(true); + + const hasFilter = searchQueryJSON?.flatFilters?.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS); + expect(hasFilter).toBeDefined(); + expect(hasFilter?.filters?.some((filter) => filter.value === CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION)).toBe(true); + + expect(searchQueryJSON?.limit).toBe(CONST.SEARCH.TOP_SEARCH_LIMIT); + }); + + test('Should return Violations by submitter search query string with correct format', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined); + const violationsBySubmitterSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]; + const searchQuery = violationsBySubmitterSearch.searchQuery; + + expect(searchQuery).toContain(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE}`); + expect(searchQuery).toContain(`groupBy:${CONST.SEARCH.GROUP_BY.FROM}`); + expect(searchQuery).toContain(`submitted:${CONST.SEARCH.DATE_PRESETS.LAST_MONTH}`); + expect(searchQuery).toContain(`has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`); + expect(searchQuery).toContain(`view:${CONST.SEARCH.VIEW.TABLE}`); + expect(searchQuery).toContain(`limit:${CONST.SEARCH.TOP_SEARCH_LIMIT}`); + expect(searchQuery).toContain(`sortBy:${CONST.SEARCH.TABLE_COLUMNS.GROUP_EXPENSES}`); + expect(searchQuery).toContain(`sortOrder:${CONST.SEARCH.SORT_ORDER.DESC}`); + }); + + test('Should return Violations by submitter search with valid hash', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined); + const violationsBySubmitterSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]; + + expect(violationsBySubmitterSearch.hash).toBeGreaterThan(0); + expect(violationsBySubmitterSearch.similarSearchHash).toBeGreaterThan(0); + }); }); describe('Test getColumnsToShow', () => { @@ -10678,6 +10916,160 @@ describe('SearchUIUtils', () => { expect(strictColumns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TO); }); + test('Should not show Violations on expense search when no submitted-violation data is present', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const tx = { + ...baseTransaction, + transactionID: 'no-submitted-violations', + merchant: 'Test Merchant', + modifiedMerchant: '', + reportID, + }; + + // @ts-expect-error minimal dataset for getColumnsToShow + const data: OnyxTypes.SearchResults['data'] = { + [`report_${reportID}`]: searchResults.data[`report_${reportID}`], + [`transactions_${tx.transactionID}`]: tx, + [`reportActions_${reportID}`]: { + '1': { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + }, + }, + }, + personalDetailsList: searchResults.data.personalDetailsList, + }; + + const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data, visibleColumns: []}); + expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + }); + + test('Should show Violations only when submitted-violation data is present', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const tx = { + ...baseTransaction, + transactionID: 'with-submitted-violations', + merchant: 'Test Merchant', + modifiedMerchant: '', + reportID, + }; + + // @ts-expect-error minimal dataset for getColumnsToShow + const data: OnyxTypes.SearchResults['data'] = { + [`report_${reportID}`]: searchResults.data[`report_${reportID}`], + [`transactions_${tx.transactionID}`]: tx, + [`reportActions_${reportID}`]: { + '1': { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + violations: { + transactions: { + [tx.transactionID]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }, + }, + }, + }, + personalDetailsList: searchResults.data.personalDetailsList, + }; + + const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data, visibleColumns: []}); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + }); + + test('Should inject Violations into custom column layouts when submitted-violation data is present', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const tx = { + ...baseTransaction, + transactionID: 'custom-columns-submitted-violations', + merchant: 'Test Merchant', + modifiedMerchant: '', + category: 'Advertising', + reportID, + }; + const customVisibleColumns = [ + CONST.SEARCH.TABLE_COLUMNS.RECEIPT, + CONST.SEARCH.TABLE_COLUMNS.DATE, + CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.CATEGORY, + CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, + ]; + + // @ts-expect-error minimal dataset for getColumnsToShow + const data: OnyxTypes.SearchResults['data'] = { + [`report_${reportID}`]: searchResults.data[`report_${reportID}`], + [`transactions_${tx.transactionID}`]: tx, + [`reportActions_${reportID}`]: { + '1': { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + violations: { + transactions: { + [tx.transactionID]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }, + }, + }, + }, + personalDetailsList: searchResults.data.personalDetailsList, + }; + + const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data, visibleColumns: customVisibleColumns}); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + expect(columns.indexOf(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS)).toBeLessThan(columns.indexOf(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT)); + }); + + test('Should keep Violations in custom column layouts when the user selected it', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const tx = { + ...baseTransaction, + transactionID: 'custom-columns-selected-violations', + merchant: 'Test Merchant', + modifiedMerchant: '', + reportID, + }; + const customVisibleColumns = [ + CONST.SEARCH.TABLE_COLUMNS.RECEIPT, + CONST.SEARCH.TABLE_COLUMNS.DATE, + CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, + CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, + ]; + + // @ts-expect-error minimal dataset for getColumnsToShow + const data: OnyxTypes.SearchResults['data'] = { + [`report_${reportID}`]: searchResults.data[`report_${reportID}`], + [`transactions_${tx.transactionID}`]: tx, + [`reportActions_${reportID}`]: { + '1': { + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + }, + }, + }, + personalDetailsList: searchResults.data.personalDetailsList, + }; + + const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data, visibleColumns: customVisibleColumns}); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + }); + test('Should only show Category GL Code when that column is selected', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const transactionWithCategoryGLCode = { @@ -10774,6 +11166,12 @@ describe('SearchUIUtils', () => { expect(tagGLCodeHeader?.sortColumnName).toBe(CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE); }); + test('Should exclude Violations from sort options', () => { + expect(SearchUIUtils.getSortByOptions([CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE], translateLocal)).toEqual([ + {text: translateLocal('common.categoryGLCode'), value: CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE}, + ]); + }); + test('Should show MCC whenever that column is selected, even with no displayable MCC', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const transactionWithoutMCC = { @@ -12471,6 +12869,174 @@ describe('SearchUIUtils', () => { }); }); + describe('getSubmittedViolationsForTransaction', () => { + const transactionIDForViolations = 'tx-violations-1'; + const otherTransactionID = 'tx-violations-2'; + + const createSubmittedAction = ( + actionName: typeof CONST.REPORT.ACTIONS.TYPE.SUBMITTED | typeof CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED, + violations?: {transactions: Record>}, + reportActionID = 'submit-action-1', + ): OnyxTypes.ReportAction => + ({ + reportActionID, + actionName, + created: '2025-01-01 00:00:00', + originalMessage: { + amount: 1000, + currency: CONST.CURRENCY.USD, + ...(violations ? {violations} : {}), + }, + }) as OnyxTypes.ReportAction; + + test('returns undefined when reportActions or transactionID is missing', () => { + expect(SearchUIUtils.getSubmittedViolationsForTransaction(undefined, transactionIDForViolations, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getSubmittedViolationsForTransaction([], transactionIDForViolations, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getSubmittedViolationsForTransaction([createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED)], undefined, translateLocal)).toBeUndefined(); + }); + + test('ignores non-submit report actions', () => { + const iouAction: OnyxTypes.ReportAction = { + reportActionID: 'iou-1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2025-01-01 00:00:00', + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: transactionIDForViolations, + }, + }; + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([iouAction], transactionIDForViolations, translateLocal)).toBeUndefined(); + }); + + test('returns comma-separated translated violation labels from a SUBMITTED action', () => { + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.MISSING_COMMENT}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBe( + `${translateLocal('violations.shortName.missingCategory')}, ${translateLocal('violations.shortName.missingComment')}`, + ); + }); + + test('includes translated labels from SUBMITTED_AND_CLOSED actions', () => { + const submitAndCloseAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.RECEIPT_REQUIRED}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAndCloseAction], transactionIDForViolations, translateLocal)).toBe( + translateLocal('violations.shortName.receiptRequired'), + ); + }); + + test('aggregates across multiple submit actions and dedupes by name', () => { + const firstSubmit = createSubmittedAction( + CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.MISSING_TAG}], + }, + }, + 'submit-1', + ); + const secondSubmit = createSubmittedAction( + CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED, + { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}, {name: CONST.VIOLATIONS.RECEIPT_REQUIRED}], + }, + }, + 'submit-2', + ); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([firstSubmit, secondSubmit], transactionIDForViolations, translateLocal)).toBe( + `${translateLocal('violations.shortName.missingCategory')}, ${translateLocal('violations.shortName.missingTag')}, ${translateLocal('violations.shortName.receiptRequired')}`, + ); + }); + + test('omits receiptRequired when itemizedReceiptRequired is also present', () => { + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.RECEIPT_REQUIRED}, {name: CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBe( + translateLocal('violations.shortName.itemizedReceiptRequired'), + ); + }); + + test('keeps receiptRequired when itemizedReceiptRequired is not present', () => { + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.RECEIPT_REQUIRED}, {name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBe( + `${translateLocal('violations.shortName.receiptRequired')}, ${translateLocal('violations.shortName.missingCategory')}`, + ); + }); + + test('translates maxAge via violations.shortName', () => { + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.MAX_AGE}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBe(translateLocal('violations.shortName.maxAge')); + expect(translateLocal('violations.shortName.maxAge')).toBe('Date older than max expense age'); + }); + + test('translates fieldRequired via violations.shortName', () => { + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [transactionIDForViolations]: [{name: CONST.VIOLATIONS.FIELD_REQUIRED}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBe(translateLocal('violations.shortName.fieldRequired')); + }); + + test('falls back to the raw identifier when no short-name translation exists', () => { + const unknownViolationName = 'unknownSubmittedViolation'; + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [transactionIDForViolations]: [{name: unknownViolationName}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBe(unknownViolationName); + }); + + test('ignores violations for other transaction IDs', () => { + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [otherTransactionID]: [{name: CONST.VIOLATIONS.MISSING_CATEGORY}], + }, + }); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBeUndefined(); + }); + + test('returns undefined when submit actions have no violations for the transaction', () => { + const submitAction = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED, { + transactions: { + [transactionIDForViolations]: [], + }, + }); + const submitActionWithoutViolations = createSubmittedAction(CONST.REPORT.ACTIONS.TYPE.SUBMITTED); + + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitAction], transactionIDForViolations, translateLocal)).toBeUndefined(); + expect(SearchUIUtils.getSubmittedViolationsForTransaction([submitActionWithoutViolations], transactionIDForViolations, translateLocal)).toBeUndefined(); + }); + }); + describe('getDisplayValue', () => { test('returns translated has option labels from getHasOptions', () => { const result = SearchUIUtils.getDisplayValue('has', {has: [CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION]}, CONST.SEARCH.DATA_TYPES.EXPENSE, translateLocal, localeCompare); diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap index 23b2ccc516da..d415876f37bb 100644 --- a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap +++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap @@ -51,6 +51,7 @@ exports[`Column availability single source of truth the derived picker lists mat "attendees", "totalPerAttendee", "tag", + "violations", "tagGLCode", "exchangeRate", "originalamount", diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index a4c0a0cabaa7..2475e8505b1d 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -151,6 +151,7 @@ const spendIcons = { Pencil: mockIcon, ThumbsUp: mockIcon, CheckCircle: mockIcon, + UserEye: mockIcon, }; function createSpendMenuItem( diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 8b393014cf6f..7e6d1a4bf230 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -40,6 +40,7 @@ jest.mock('@libs/SearchUIUtils', () => ({ getTableMinWidth: jest.fn(() => 0), getSuggestedSearches: jest.fn(() => ({})), getSuggestedSearchesVisibility: jest.fn(() => ({topSpendersPolicyIDs: []})), + getSubmittedViolationsForTransaction: jest.fn(() => ''), })); jest.mock('@react-navigation/native', () => ({ diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index bd32afb4805f..59fa6c7b2bb5 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -197,14 +197,27 @@ jest.mock('@hooks/useUndeleteTransactions', () => ({ default: () => jest.fn(), })); -jest.mock('@libs/SearchUIUtils', () => ({ - shouldShowDeleteOption: () => false, - getSelectedGroupFilterEntry: jest.fn(), - navigateToSearchRHP: jest.fn(), - getValidGroupBy: jest.fn((groupBy?: string) => groupBy), - getSearchColumnTranslationKey: jest.fn((column: string) => column), - getColumnsToShow: jest.fn(() => []), -})); +jest.mock('@libs/SearchUIUtils', () => { + return { + shouldShowDeleteOption: () => false, + getSelectedGroupFilterEntry: jest.fn(), + navigateToSearchRHP: jest.fn(), + getValidGroupBy: jest.fn((groupBy?: string) => groupBy), + getSearchColumnTranslationKey: jest.fn((column: string) => column), + getColumnsToShow: jest.fn(() => []), + insertColumnBeforeTotalAmount: (columns: string[], columnId: string) => { + if (columns.includes(columnId)) { + return; + } + const totalAmountIndex = columns.findIndex((column) => column === 'amount'); + if (totalAmountIndex === -1) { + columns.push(columnId); + return; + } + columns.splice(totalAmountIndex, 0, columnId); + }, + }; +}); jest.mock('@hooks/useDuplicateTransactionsAndViolations', () => ({ __esModule: true, @@ -293,6 +306,23 @@ const groupedExpenseQueryJSON: SearchQueryJSON = { groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, }; +const groupedSubmittedViolationQueryJSON: SearchQueryJSON = { + ...groupedExpenseQueryJSON, + inputQuery: `type:expense groupBy:${CONST.SEARCH.GROUP_BY.FROM} has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`, + groupBy: CONST.SEARCH.GROUP_BY.FROM, + flatFilters: [ + { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + value: CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION, + }, + ], + }, + ], +}; + function makeSelectedReport(overrides: Partial = {}): SelectedReports { return { reportID: REPORT_ID, @@ -1171,6 +1201,64 @@ describe('useSearchBulkActions - export options', () => { expect(columnLabels).toEqual(Object.fromEntries(expectedColumns.map((column) => [column, column]))); }); + it('exports Violations on a grouped search that filters by submitted-violation even without saved columns', async () => { + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedSubmittedViolationQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')).toBeDefined(); + }); + + getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')?.onSelected?.(); + + await waitFor(() => { + expect(exportSearchItemsToCSV).toHaveBeenCalled(); + }); + + const expectedColumns: string[] = [CONST.SEARCH.TABLE_COLUMNS.TYPE, ...Object.values(CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE)]; + const violationsIndex = expectedColumns.indexOf(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); + expectedColumns.splice(violationsIndex, 0, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + + const {isBasicExport, query, columnLabels} = getLastCSVExportParameters(); + expect(isBasicExport).toBe(false); + expect(query).toEqual(expect.objectContaining({columns: expectedColumns})); + expect(columnLabels).toEqual(Object.fromEntries(expectedColumns.map((column) => [column, column]))); + }); + + it('exports Violations on a grouped submitted-violation search even when saved columns omit it', async () => { + await Onyx.merge(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, { + columns: [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL, CONST.SEARCH.TABLE_COLUMNS.TAG, CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.FROM], + }); + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedSubmittedViolationQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')).toBeDefined(); + }); + + getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')?.onSelected?.(); + + await waitFor(() => { + expect(exportSearchItemsToCSV).toHaveBeenCalled(); + }); + + const {isBasicExport, query} = getLastCSVExportParameters(); + expect(isBasicExport).toBe(false); + expect(query).toEqual( + expect.objectContaining({ + columns: [ + CONST.SEARCH.TABLE_COLUMNS.TYPE, + CONST.SEARCH.TABLE_COLUMNS.TAG, + CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.FROM, + CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, + ], + }), + ); + }); + it('exports the current view of a grouped search with the configured expense columns in order', async () => { await Onyx.merge(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, { columns: [CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL, CONST.SEARCH.TABLE_COLUMNS.TAG, CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.FROM],