diff --git a/src/CONST/index.ts b/src/CONST/index.ts index da39383cace8..c4cd74e7056e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6901,6 +6901,11 @@ const CONST = { reportView: true, }, TAG: {column: this.TABLE_COLUMNS.TAG, search: true, reportView: true}, + VIOLATIONS: { + column: this.TABLE_COLUMNS.VIOLATIONS, + search: false, + reportView: false, + }, TAG_GL_CODE: { column: this.TABLE_COLUMNS.TAG_GL_CODE, search: true, @@ -7212,6 +7217,7 @@ const CONST = { TO: 'to', CATEGORY: 'category', TAG: 'tag', + VIOLATIONS: 'violations', ORIGINAL_AMOUNT: 'originalamount', REIMBURSABLE: 'reimbursable', BILLABLE: 'billable', @@ -7437,6 +7443,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', @@ -7535,6 +7542,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 47746cb3dc2e..a779cd95f0c3 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -330,7 +330,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 ( @@ -378,7 +378,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 9981a9360482..ec8302afb964 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 9eed61acb455..3564f2d8f66e 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, nonPersonalAndWorkspaceCards, @@ -210,7 +210,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 b2b31f498374..7f21a7b4d9a8 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -120,7 +120,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)}`); @@ -134,7 +134,7 @@ function TransactionListItemInner({ const [transactionViolationsForRow] = originalUseOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${getNonEmptyStringOnyxID(transactionItem.transactionID)}`); 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; @@ -156,7 +156,7 @@ function TransactionListItemInner({ reportID: transactionItem.reportID, itemKey: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`, snapshotData, - snapshotActions: exportedReportActions, + snapshotActions: reportActions, enabled: !!snapshotData, }); const transactionPreviewData: TransactionPreviewData = { @@ -260,7 +260,7 @@ function TransactionListItemInner({ handleActionButtonPress, shouldDisableActionPointerEvents: shouldDisableSearchSubmitPress, transactionPreviewData, - exportedReportActions, + reportActions, policyCategories, policyTagLists, nonPersonalAndWorkspaceCards, diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts index e68bd3a40cda..302c919a514a 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; nonPersonalAndWorkspaceCards?: CardList; diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index cdeb0ab1ed2b..e7f023b8eb53 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 ffca77cdfab3..94a0cad70513 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -26,6 +26,7 @@ import getBase62ReportID from '@libs/getBase62ReportID'; import {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, @@ -155,6 +156,7 @@ function TransactionItemRowWide({ const submitterUserID = reportForCustomColumns?.submitterUserID; const submitterPayrollID = reportForCustomColumns?.submitterPayrollID; const orderDealNumbers = reportForCustomColumns?.orderDealNumbers; + const submittedViolations = getSubmittedViolationsForTransaction(reportActions, transactionItem.transactionID, translate); const hasValidationMessage = shouldShowErrors && (!!missingFieldError || !!violations?.length); let fullHeightMainRowStyle; if (shouldUseFullHeightEditableCellHoverTarget) { @@ -205,6 +207,15 @@ function TransactionItemRowWide({ /> ); + case CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS: + return ( + + + + ); case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: return ( ): OnyxEntry => achAccount: policy.achAccount, areCategoriesEnabled: policy.areCategoriesEnabled, areWorkflowsEnabled: policy.areWorkflowsEnabled, + areRulesEnabled: policy.areRulesEnabled, }; const currentUserLoginAndAccountIDSelector = (session: OnyxEntry) => ({ diff --git a/src/languages/de.ts b/src/languages/de.ts index 7985462fac08..a566322b0c95 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -333,6 +333,7 @@ const translations: TranslationDeepObject = { billable: 'Abrechenbar', nonBillable: 'Nicht abrechenbar', tag: 'Tag', + violations: 'Violations', receipt: 'Beleg', verified: 'Verifiziert', replace: 'Ersetzen', @@ -9168,6 +9169,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: 'Violations by submitter', }, }, genericErrorPage: { @@ -9833,7 +9835,52 @@ 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: '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: 'Expense too old', + 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', + 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} ist erforderlich`, reportContainsExpensesWithViolations: 'Der Bericht enthält Ausgaben mit Verstößen.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 076f253dd641..c2529bc1b83d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -359,6 +359,7 @@ const translations = { billable: 'Billable', nonBillable: 'Non-billable', tag: 'Tag', + violations: 'Violations', receipt: 'Receipt', verified: 'Verified', replace: 'Replace', @@ -9025,6 +9026,7 @@ const translations = { topSpenders: 'Top spenders', topCategories: 'Top categories', topMerchants: 'Top merchants', + violationsBySubmitter: 'Violations by submitter', }, resultsAreLimited: 'Search results are limited.', viewResults: 'View results', @@ -9975,6 +9977,54 @@ 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: 'Expense too old', + 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', + 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 8aafd38e5a39..e38391ec27ae 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -292,6 +292,7 @@ const translations: TranslationDeepObject = { billable: 'Facturable', nonBillable: 'No facturable', tag: 'Etiqueta', + violations: 'Violations', receipt: 'Recibo', verified: 'Verificado', replace: 'Sustituir', @@ -8711,6 +8712,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, topSpenders: 'Mayores gastadores', topCategories: 'Categorías principales', topMerchants: 'Principales comerciantes', + violationsBySubmitter: 'Violations by submitter', }, resultsAreLimited: 'Los resultados de búsqueda están limitados.', viewResults: 'Ver resultados', @@ -9999,7 +10001,52 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, 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: '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: 'Expense too old', + 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', + 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) => `${fieldName} es obligatorio`, reportContainsExpensesWithViolations: 'El informe contiene gastos con violaciones.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 03fc902ca84c..f0925a8ffc39 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -333,6 +333,7 @@ const translations: TranslationDeepObject = { billable: 'Facturable', nonBillable: 'Non refacturable', tag: 'Tag', + violations: 'Violations', receipt: 'Reçu', verified: 'Vérifié', replace: 'Remplacer', @@ -9204,6 +9205,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: 'Violations by submitter', }, }, genericErrorPage: { @@ -9869,7 +9871,52 @@ 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: '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: 'Expense too old', + 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', + 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} est obligatoire`, reportContainsExpensesWithViolations: 'La note de frais contient des dépenses avec des violations.', diff --git a/src/languages/it.ts b/src/languages/it.ts index d5d143f1be5f..eda9ff50b58a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -333,6 +333,7 @@ const translations: TranslationDeepObject = { billable: 'Fatturabile', nonBillable: 'Non fatturabile', tag: 'Etichetta', + violations: 'Violations', receipt: 'Ricevuta', verified: 'Verificato', replace: 'Sostituisci', @@ -9145,6 +9146,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, topSpenders: 'Maggiori spendaccioni', topCategories: 'Categorie principali', topMerchants: 'Principali esercenti', + violationsBySubmitter: 'Violations by submitter', }, }, genericErrorPage: { @@ -9811,7 +9813,52 @@ 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: '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: 'Expense too old', + 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', + 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} è obbligatorio`, reportContainsExpensesWithViolations: 'Il report contiene spese con violazioni.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 22bdedf0bf98..334f5aaa91ff 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -333,6 +333,7 @@ const translations: TranslationDeepObject = { billable: '請求可能', nonBillable: '請求不可', tag: 'タグ', + violations: 'Violations', receipt: 'レシート', verified: '確認済み', replace: '置換', @@ -9019,6 +9020,7 @@ ${reportName}`, topSpenders: '上位の支出者', topCategories: '上位カテゴリ', topMerchants: '上位加盟店', + violationsBySubmitter: 'Violations by submitter', }, }, genericErrorPage: { @@ -9677,7 +9679,52 @@ ${reportName}`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `料金は${startDate}からのみ有効です`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `料金は${endDate}までのみ有効です`, cannotMergeDuplicates: '経費を統合できるのは、下書きまたは未清算のレポートのみです。レポートを取り下げて、もう一度お試しください。', + 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: 'Expense too old', + 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', + 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} は必須です`, reportContainsExpensesWithViolations: 'レポートに違反のある経費が含まれています。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2b7e77d15664..21f18bd6a709 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -333,6 +333,7 @@ const translations: TranslationDeepObject = { billable: 'Factureerbaar', nonBillable: 'Niet-factureerbaar', tag: 'Label', + violations: 'Violations', receipt: 'Bonnetje', verified: 'Geverifieerd', replace: 'Vervangen', @@ -9116,6 +9117,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, topSpenders: 'Grootste uitgaven doeners', topCategories: 'Topcategorieën', topMerchants: 'Topverkopers', + violationsBySubmitter: 'Violations by submitter', }, }, genericErrorPage: { @@ -9781,7 +9783,52 @@ 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: '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: 'Expense too old', + 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', + 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 verplicht`, reportContainsExpensesWithViolations: 'Rapport bevat onkosten met overtredingen.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index bb4db669b8d7..ddcbde51a93b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -333,6 +333,7 @@ const translations: TranslationDeepObject = { billable: 'Fakturowalne', nonBillable: 'Nierozliczalne', tag: 'Tag', + violations: 'Violations', receipt: 'Paragon', verified: 'Zweryfikowano', replace: 'Zastąp', @@ -9093,6 +9094,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: 'Violations by submitter', }, }, genericErrorPage: { @@ -9752,7 +9754,52 @@ 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: '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: 'Expense too old', + 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', + 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) => `Pole ${fieldName} jest wymagane`, reportContainsExpensesWithViolations: 'Raport zawiera wydatki z naruszeniami.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 550ac8da144d..929c2be1d858 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -333,6 +333,7 @@ const translations: TranslationDeepObject = { billable: 'Faturável', nonBillable: 'Não faturável', tag: 'Etiqueta', + violations: 'Violations', receipt: 'Recibo', verified: 'Verificado', replace: 'Substituir', @@ -9106,6 +9107,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, topSpenders: 'Maiores gastadores', topCategories: 'Principais categorias', topMerchants: 'Principais estabelecimentos', + violationsBySubmitter: 'Violations by submitter', }, }, genericErrorPage: { @@ -9770,7 +9772,52 @@ 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: '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: 'Expense too old', + 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', + 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} é obrigatório`, reportContainsExpensesWithViolations: 'O relatório contém despesas com violações.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index bd140d2ad548..c28b94bf4d03 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -333,6 +333,7 @@ const translations: TranslationDeepObject = { billable: '可计费', nonBillable: '不可计费', tag: '标签', + violations: 'Violations', receipt: '收据', verified: '已验证', replace: '替换', @@ -8796,6 +8797,7 @@ ${reportName}`, topSpenders: '最高消费者', topCategories: '热门类别', topMerchants: '热门商家', + violationsBySubmitter: 'Violations by submitter', }, }, genericErrorPage: { @@ -9449,7 +9451,52 @@ ${reportName}`, customUnitRateOutOfDateRangeStartOnly: ({startDate}: {startDate: string}) => `费率仅自 ${startDate} 起有效`, customUnitRateOutOfDateRangeEndOnly: ({endDate}: {endDate: string}) => `该费率仅在 ${endDate} 之前有效`, cannotMergeDuplicates: '您只能在草稿或未结报销单中合并报销。请先撤回后重试。', + 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: 'Expense too old', + 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', + 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} 为必填项`, reportContainsExpensesWithViolations: '报表包含有违规的报销。', diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 96480b001db3..15a71952b2eb 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4922,6 +4922,7 @@ export { isHoldAction, isWhisperAction, isSubmittedAction, + isSubmittedAndClosedAction, isDynamicExternalWorkflowSubmitAction, isMarkAsClosedAction, isForwardedAction, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 0b2b2514b1c3..c1c1c287afa3 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -120,6 +120,7 @@ import Parser from './Parser'; import {getLoginByAccountID, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; import { arePaymentsEnabled, + arePolicyRulesEnabled, canSendInvoice, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, @@ -142,6 +143,8 @@ import { isMoneyRequestAction, isReportActionVisible, isResolvedActionableWhisper, + isSubmittedAction, + isSubmittedAndClosedAction, isWhisperActionTargetedToOthers, } from './ReportActionsUtils'; import {deprecatedGetReportName} from './ReportNameUtils'; @@ -228,6 +231,7 @@ import { import getFormattedPostedDate from './TransactionUtils/getFormattedPostedDate'; import shouldShowTransactionPostedYear from './TransactionUtils/shouldShowTransactionPostedYear'; import {isInvalidMerchantValue} from './ValidationUtils'; +import {isValidViolationName} from './Violations/ViolationsUtils'; type ColumnSortMapping = Partial>; type ColumnVisibility = Partial>; @@ -432,6 +436,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 { @@ -561,6 +566,7 @@ const SEARCH_TYPE_MENU_ICON_NAMES = [ 'CreditCardHourglass', 'Bank', 'User', + 'UserEye', 'Folder', 'Basket', 'CalendarSolid', @@ -1042,6 +1048,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, + dateOn: 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', @@ -1113,6 +1151,7 @@ function getSuggestedSearchesVisibility( let shouldShowTopSpendersSuggestion = false; let shouldShowTopCategoriesSuggestion = false; let shouldShowTopMerchantsSuggestion = false; + let shouldShowViolationsBySubmitterSuggestion = false; let hasGroupPoliciesWithExpenseChat = false; let shouldShowSpendOverTimeSuggestion = false; const topSpendersPolicyIDs: string[] = []; @@ -1158,6 +1197,7 @@ 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) && memberCount >= 2; shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion; shouldShowPaySuggestion ||= isEligibleForPaySuggestion; @@ -1174,6 +1214,7 @@ function getSuggestedSearchesVisibility( } shouldShowTopCategoriesSuggestion ||= isEligibleForTopCategoriesSuggestion; shouldShowTopMerchantsSuggestion ||= isEligibleForTopMerchantsSuggestion; + shouldShowViolationsBySubmitterSuggestion ||= isEligibleForViolationsBySubmitterSuggestion; hasGroupPoliciesWithExpenseChat ||= isGroupPolicyEligible && !!policy.isPolicyExpenseChatEnabled && @@ -1198,6 +1239,7 @@ function getSuggestedSearchesVisibility( [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: shouldShowTopSpendersSuggestion, [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, }, hasGroupPoliciesWithExpenseChat, @@ -4468,6 +4510,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: @@ -4853,6 +4897,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS ...(!isTrackIntentWithWorkflowsDisabled ? [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) { @@ -4969,6 +5014,118 @@ function getHasOptions(translate: LocalizedTranslate, type: SearchDataTypes) { } } +/** Parameter-free short labels used when rendering submitted violations in Search columns. */ +const SUBMITTED_TRANSACTION_VIOLATION_SHORT_NAMES = [ + CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED, + CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE, + CONST.VIOLATIONS.BILLABLE_EXPENSE, + CONST.VIOLATIONS.CASH_EXPENSE_WITH_NO_RECEIPT, + CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, + CONST.VIOLATIONS.COMPANY_CARD_REQUIRED, + CONST.VIOLATIONS.CONVERSION_SURCHARGE, + CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY, + CONST.VIOLATIONS.CUSTOM_UNIT_RATE_OUT_OF_DATE_RANGE, + CONST.VIOLATIONS.DUPLICATED_TRANSACTION, + CONST.VIOLATIONS.FIELD_REQUIRED, + CONST.VIOLATIONS.FUTURE_DATE, + CONST.VIOLATIONS.HOLD, + CONST.VIOLATIONS.INACTIVE_VENDOR, + CONST.VIOLATIONS.INCREASED_DISTANCE, + CONST.VIOLATIONS.INVOICE_MARKUP, + CONST.VIOLATIONS.ITEMIZED_RECEIPT_REQUIRED, + CONST.VIOLATIONS.MAX_AGE, + CONST.VIOLATIONS.MISSING_ATTENDEES, + CONST.VIOLATIONS.MISSING_CATEGORY, + CONST.VIOLATIONS.MISSING_COMMENT, + CONST.VIOLATIONS.MISSING_TAG, + CONST.VIOLATIONS.MODIFIED_AMOUNT, + CONST.VIOLATIONS.MODIFIED_DATE, + CONST.VIOLATIONS.NO_ROUTE, + CONST.VIOLATIONS.NON_EXPENSIWORKS_EXPENSE, + CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, + CONST.VIOLATIONS.OVER_CATEGORY_LIMIT, + CONST.VIOLATIONS.OVER_LIMIT, + CONST.VIOLATIONS.OVER_TRIP_LIMIT, + CONST.VIOLATIONS.PER_DAY_LIMIT, + CONST.VIOLATIONS.PROHIBITED_EXPENSE, + CONST.VIOLATIONS.RECEIPT_GENERATED_WITH_AI, + CONST.VIOLATIONS.RECEIPT_NOT_SMART_SCANNED, + CONST.VIOLATIONS.RECEIPT_REQUIRED, + CONST.VIOLATIONS.RTER, + CONST.VIOLATIONS.SMARTSCAN_FAILED, + CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED, + CONST.VIOLATIONS.TAX_AMOUNT_CHANGED, + CONST.VIOLATIONS.TAX_OUT_OF_POLICY, + CONST.VIOLATIONS.TAX_RATE_CHANGED, + CONST.VIOLATIONS.TAX_REQUIRED, +] as const satisfies ReadonlyArray>; + +type SubmittedTransactionViolationShortName = TupleToUnion; + +const SUBMITTED_TRANSACTION_VIOLATION_SHORT_NAME_SET = new Set(SUBMITTED_TRANSACTION_VIOLATION_SHORT_NAMES); + +function isSubmittedTransactionViolationShortName(name: ValueOf): 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 { + if (!isValidViolationName(violationName)) { + return violationName; + } + + if (isSubmittedTransactionViolationShortName(violationName)) { + return translate(`violations.shortName.${violationName}`); + } + + return 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. + */ +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; + } + + 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}, @@ -6056,6 +6213,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, @@ -6089,6 +6247,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, @@ -6199,6 +6358,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. @@ -6310,6 +6476,14 @@ function getColumnsToShow({ } if (customResult) { + if (columns[CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS] && !customResult.includes(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS)) { + const totalAmountIndex = customResult.indexOf(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); + if (totalAmountIndex === -1) { + customResult.push(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + } else { + customResult.splice(totalAmountIndex, 0, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + } + } return customResult; } @@ -6577,6 +6751,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, @@ -6709,6 +6884,7 @@ export { getWithdrawalStatusDisplayText, getColumnsToShow, getHasOptions, + getSubmittedViolationsForTransaction, getSettlementStatus, getSettlementStatusBadgeProps, getSearchColumnTranslationKey, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 53dd27f6aa0e..b3494973ebf9 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -67,6 +67,13 @@ type ViolationTranslationParams = { distanceUnit?: Unit; }; +/** + * Type guard that narrows a string to a known transaction violation name. + */ +function isValidViolationName(name: string): name is ViolationName { + return Object.values(CONST.VIOLATIONS).some((violationName) => violationName === name); +} + /** * Filters out receiptRequired violation when itemizedReceiptRequired is also present. * Itemized receipt requirement supersedes regular receipt requirement. @@ -1190,6 +1197,6 @@ const ViolationsUtils = { }, }; -export {getIsViolationFixed, isHardViolationOrRateDateWarning, syncCustomUnitRateOutOfDateRangeViolation}; +export {getIsViolationFixed, isHardViolationOrRateDateWarning, isValidViolationName, syncCustomUnitRateOutOfDateRangeViolation}; export default ViolationsUtils; export {filterReceiptViolations}; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index b62a7e633c85..02556750a1bb 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2004,6 +2004,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 39d44235a443..e0133d728807 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/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index c85a6e273d91..571f9aa3fea0 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -9083,6 +9083,114 @@ 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 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}`; @@ -9601,6 +9709,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 dateFilter = searchQueryJSON?.flatFilters?.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); + expect(dateFilter).toBeDefined(); + expect(dateFilter?.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(`date:${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', () => { @@ -9686,6 +9860,121 @@ 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 only show Category GL Code when that column is selected', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const transactionWithCategoryGLCode = { @@ -9782,6 +10071,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 = { @@ -11474,6 +11769,139 @@ 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('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/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 5cee95dedfba..f69580f28a8d 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -102,6 +102,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 018465698a13..e8aa568ffdb3 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -39,6 +39,7 @@ jest.mock('@libs/SearchUIUtils', () => ({ getTableMinWidth: jest.fn(() => 0), getSuggestedSearches: jest.fn(() => ({})), getSuggestedSearchesVisibility: jest.fn(() => ({topSpendersPolicyIDs: []})), + getSubmittedViolationsForTransaction: jest.fn(() => ''), })); jest.mock('@react-navigation/native', () => ({