From 43f3bebd872216bd4936afa320d05e1ebdb4ed33 Mon Sep 17 00:00:00 2001 From: nkdengineer <161821005+nkdengineer@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:08:50 +0700 Subject: [PATCH 1/9] Revert "[CP stag] Revert "[Insights: Violations]: Expanding group & Suggested search"" --- src/CONST/index.ts | 8 + .../ListItem/TransactionGroupListExpanded.tsx | 4 +- .../TransactionListItemNarrow.tsx | 4 +- .../TransactionListItemWide.tsx | 4 +- .../ListItem/TransactionListItem/index.tsx | 8 +- .../ListItem/TransactionListItem/types.ts | 2 +- src/components/Search/SearchTableHeader.tsx | 5 + .../TransactionItemRowWide.tsx | 11 + src/hooks/useSearchShouldCalculateTotals.ts | 1 + src/hooks/useSearchTypeMenuSections.ts | 1 + src/languages/de.ts | 49 ++ src/languages/el.ts | 49 ++ src/languages/en.ts | 53 +++ src/languages/es.ts | 49 ++ src/languages/fr.ts | 49 ++ src/languages/it.ts | 49 ++ src/languages/ja.ts | 49 ++ src/languages/nl.ts | 49 ++ src/languages/pl.ts | 49 ++ src/languages/pt-BR.ts | 49 ++ src/languages/zh-hans.ts | 49 ++ src/libs/ReportActionsUtils.ts | 1 + src/libs/SearchUIUtils.ts | 121 +++++ src/styles/utils/index.ts | 4 + src/types/onyx/OriginalMessage.ts | 12 + tests/unit/Search/SearchUIUtilsTest.ts | 428 ++++++++++++++++++ tests/unit/SearchRouterNavigationTest.ts | 1 + tests/unit/TransactionGroupListItemTest.tsx | 1 + 28 files changed, 1148 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 423a5b21e1d0..fb9491fac617 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6999,6 +6999,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, @@ -7314,6 +7319,7 @@ const CONST = { TO: 'to', CATEGORY: 'category', TAG: 'tag', + VIOLATIONS: 'violations', ORIGINAL_AMOUNT: 'originalamount', REIMBURSABLE: 'reimbursable', BILLABLE: 'billable', @@ -7547,6 +7553,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', @@ -7647,6 +7654,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 ec5c30274425..7bfb5429837e 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -332,7 +332,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 ( @@ -380,7 +380,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 f7c793f0b5cb..0658419702be 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -121,7 +121,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)}`); @@ -135,7 +135,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 = { @@ -263,7 +263,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 06cf806653ad..7ad5f30bf746 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 0a4dcd3046cc..bcece931e2ae 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, @@ -156,6 +157,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) { @@ -208,6 +210,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 c02737885786..16af05fdb3f3 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -294,6 +294,7 @@ const translations: TranslationDeepObject = { billable: 'Abrechenbar', nonBillable: 'Nicht abrechenbar', tag: 'Tag', + violations: 'Verstöße', 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: 'Verstöße nach Einreichender', }, }, genericErrorPage: { @@ -9844,6 +9846,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: 'Wechselkurs für Arbeitsbereich ungültig', + customUnitRateOutOfDateRange: 'Satz außerhalb gültiger Daten', + duplicatedTransaction: 'Möglicher Duplikat', + fieldRequired: 'Berichts­feld erforderlich', + futureDate: 'Zukünftiges Datum nicht erlaubt', + hold: 'Spesen zurückgestellt', + inactiveVendor: 'Lieferant nicht mehr gültig', + increasedDistance: 'Entfernung überschreitet Route', + invoiceMarkup: 'Rechnung kommentiert', + itemizedReceiptRequired: 'Detaillierte Quittung erforderlich', + maxAge: 'Ausgabe zu alt', + missingAttendees: 'Teilnehmende erforderlich', + missingCategory: 'Fehlende Kategorie', + missingComment: 'Beschreibung erforderlich', + missingTag: 'Fehlender Tag', + modifiedAmount: 'Betrag geändert', + modifiedDate: 'Änderungsdatum', + noRoute: 'Keine gültige Route', + nonExpensiworksExpense: 'Nicht-Expensiworks-Ausgabe', + overAutoApprovalLimit: 'Über dem Limit für automatische Genehmigung', + overCategoryLimit: 'Über Kategoriegrenze', + overLimit: 'Über dem Limit', + overTripLimit: 'Über dem Reiselimit', + perDayLimit: 'Über Tageslimit', + prohibitedExpense: 'Unzulässige Ausgabe', + receiptGeneratedWithAI: 'Möglicherweise KI-generierte Quittung', + receiptNotSmartScanned: 'Beleg manuell hinzugefügt', + receiptRequired: 'Beleg erforderlich', + rter: 'Warten auf Kartenabgleich', + smartscanFailed: 'Belegerfassung fehlgeschlagen', + someTagLevelsRequired: 'Tag erforderlich', + tagOutOfPolicy: 'Tag nicht mehr gültig', + overLimitAttendee: 'Personenlimit überschritten', + 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 619da4e10e13..d560ac8aaf24 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -296,6 +296,7 @@ const translations: TranslationDeepObject = { billable: 'Χρεώσιμη', nonBillable: 'Μη χρεώσιμο', tag: 'Ετικέτα', + violations: 'Παραβάσεις', receipt: 'Απόδειξη', verified: 'Επαληθευμένο', replace: 'Αντικατάσταση', @@ -9120,6 +9121,7 @@ ${reportName}`, topSpenders: 'Κορυφαίοι δαπανώντες', topCategories: 'Κορυφαίες κατηγορίες', topMerchants: 'Κορυφαίοι έμποροι', + violationsBySubmitter: 'Παραβάσεις από τον αποστολέα', }, resultsAreLimited: 'Τα αποτελέσματα αναζήτησης είναι περιορισμένα.', viewResults: 'Προβολή αποτελεσμάτων', @@ -10082,6 +10084,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: 'Πιθανή απόδειξη που δημιουργήθηκε με τεχνητή νοημοσύνη', + 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 4257c624f813..5c4a868d6188 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -320,6 +320,7 @@ const translations = { billable: 'Billable', nonBillable: 'Non-billable', tag: 'Tag', + violations: 'Violations', receipt: 'Receipt', verified: 'Verified', replace: 'Replace', @@ -9027,6 +9028,7 @@ const translations = { topSpenders: 'Top spenders', topCategories: 'Top categories', topMerchants: 'Top merchants', + violationsBySubmitter: 'Violations by submitter', }, resultsAreLimited: 'Search results are limited.', viewResults: 'View results', @@ -9988,6 +9990,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: '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', + 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 c2f33688b721..025a3a7aebac 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -283,6 +283,7 @@ const translations: TranslationDeepObject = { billable: 'Facturable', nonBillable: 'No facturable', tag: 'Etiqueta', + violations: 'Infracciones', receipt: 'Recibo', verified: 'Verificado', replace: 'Sustituir', @@ -8751,6 +8752,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, 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', @@ -10049,6 +10051,53 @@ 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: 'Todas las etiquetas obligatorias', + autoReportedRejectedExpense: 'Gasto rechazado', + billableExpense: 'Facturable ya no es válido', + cashExpenseWithNoReceipt: 'Recibo obligatorio', + categoryOutOfPolicy: 'La categoría ya no es válida', + companyCardRequired: 'Se requiere tarjeta de la empresa', + conversionSurcharge: 'Recargo por conversión aplicado', + customUnitOutOfPolicy: 'La tasa no es válida para el espacio de trabajo', + customUnitRateOutOfDateRange: 'Tasa fuera de 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 detallado', + maxAge: 'Gasto demasiado antiguo', + missingAttendees: 'Asistentes obligatorios', + missingCategory: 'Falta la categoría', + missingComment: 'Descripción obligatoria', + missingTag: 'Etiqueta faltante', + modifiedAmount: 'Importe modificado', + modifiedDate: 'Fecha de modificación', + noRoute: 'Ruta no válida', + nonExpensiworksExpense: 'Gasto fuera de Expensiworks', + overAutoApprovalLimit: 'Límite de autoaprobación superado', + overCategoryLimit: 'Límite de categoría superado', + overLimit: 'Límite superado', + overTripLimit: 'Límite de viaje superado', + perDayLimit: 'Por encima del límite diario', + prohibitedExpense: 'Gasto prohibido', + receiptGeneratedWithAI: 'Posible recibo generado por IA', + receiptNotSmartScanned: 'Recibo añadido manualmente', + receiptRequired: 'Recibo obligatorio', + rter: 'En espera de coincidencia de tarjeta', + smartscanFailed: 'Error al escanear el recibo', + someTagLevelsRequired: 'Etiqueta obligatoria', + tagOutOfPolicy: 'Etiqueta ya no válida', + overLimitAttendee: 'Límite de personas superado', + customRules: 'Infracción 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 impuestos', + }, }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: (fieldName) => `${fieldName} es obligatorio`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d73f782c2236..61f8986ac710 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -294,6 +294,7 @@ const translations: TranslationDeepObject = { billable: 'Facturable', nonBillable: 'Non refacturable', tag: 'Tag', + violations: 'Infractions', 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: 'Infractions par déclarant', }, }, genericErrorPage: { @@ -9880,6 +9882,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 de société requise', + conversionSurcharge: 'Surtaxe 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: 'Dépense trop ancienne', + missingAttendees: 'Participants requis', + missingCategory: 'Catégorie manquante', + missingComment: 'Description obligatoire', + missingTag: 'Tag manquant', + modifiedAmount: 'Montant modifié', + modifiedDate: 'Date de modification', + noRoute: 'Aucune route 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-delà 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 rapprochement de carte', + smartscanFailed: 'L’analyse du reçu a échoué', + someTagLevelsRequired: 'Tag obligatoire', + tagOutOfPolicy: 'Tag n’est plus valide', + overLimitAttendee: 'Limite de personnes dépassée', + customRules: 'Violation de règle personnalisée', + taxAmountChanged: 'Montant de taxe modifié', + taxOutOfPolicy: 'Taux d’imposition n’est plus valide', + taxRateChanged: 'Taux de taxe 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 a26fc7d11e55..2b40c3f27298 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -294,6 +294,7 @@ const translations: TranslationDeepObject = { billable: 'Fatturabile', nonBillable: 'Non fatturabile', tag: 'Etichetta', + violations: 'Violazioni', receipt: 'Ricevuta', verified: 'Verificato', replace: 'Sostituisci', @@ -9140,6 +9141,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: { @@ -9817,6 +9819,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: 'È richiesta una carta aziendale', + conversionSurcharge: 'Maggiorazione di conversione applicata', + 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 con maggiorazione', + itemizedReceiptRequired: 'Ricevuta dettagliata richiesta', + maxAge: 'Spesa troppo vecchia', + 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: 'Limite categoria superato', + overLimit: 'Oltre il limite', + overTripLimit: 'Oltre il limite di viaggio', + perDayLimit: 'Oltre 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 5d2b26e6c14e..5e6e38248996 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -294,6 +294,7 @@ const translations: TranslationDeepObject = { billable: '請求可能', nonBillable: '請求不可', tag: 'タグ', + violations: '違反事項', receipt: 'レシート', verified: '確認済み', replace: '置換', @@ -9018,6 +9019,7 @@ ${reportName}`, topSpenders: '上位の支出者', topCategories: '上位カテゴリ', topMerchants: '上位加盟店', + violationsBySubmitter: '申請者による違反', }, }, genericErrorPage: { @@ -9687,6 +9689,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 25f00ee82bf0..802c0e57b8bc 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -294,6 +294,7 @@ const translations: TranslationDeepObject = { billable: 'Factureerbaar', nonBillable: 'Niet-factureerbaar', tag: 'Label', + violations: 'Overtredingen', receipt: 'Bonnetje', verified: 'Geverifieerd', replace: 'Vervangen', @@ -9112,6 +9113,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: { @@ -9788,6 +9790,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 vereist', + autoReportedRejectedExpense: 'Declaratie afgewezen', + billableExpense: 'Declarabel niet langer geldig', + cashExpenseWithNoReceipt: 'Bonnetje vereist', + categoryOutOfPolicy: 'Categorie niet meer geldig', + companyCardRequired: 'Bedrijfskaart vereist', + conversionSurcharge: 'Omkostentoeslag voor conversie toegepast', + customUnitOutOfPolicy: 'Tarief niet geldig voor workspace', + customUnitRateOutOfDateRange: 'Tarief buiten geldige datums', + duplicatedTransaction: 'Mogelijke duplicaat', + fieldRequired: 'Rapportveld verplicht', + futureDate: 'Toekomstige datum niet toegestaan', + hold: 'Uitgave in de wacht', + inactiveVendor: 'Leverancier niet meer geldig', + increasedDistance: 'Afstand overschrijdt route', + invoiceMarkup: 'Factuur gewijzigd', + itemizedReceiptRequired: 'Gespecificeerde bon vereist', + maxAge: 'Declaratie te oud', + missingAttendees: 'Deelnemers verplicht', + missingCategory: 'Ontbrekende categorie', + missingComment: 'Beschrijving verplicht', + missingTag: 'Ontbrekende tag', + modifiedAmount: 'Bedrag aangepast', + modifiedDate: 'Datum gewijzigd', + noRoute: 'Geen geldige route', + nonExpensiworksExpense: 'Niet-Expensiworks-uitgave', + overAutoApprovalLimit: 'Boven automatische goedkeuringslimiet', + overCategoryLimit: 'Bovengrens categorie overschreden', + overLimit: 'Bovengrens overschreden', + overTripLimit: 'Boven reislimiet', + perDayLimit: 'Boven daglimiet', + prohibitedExpense: 'Verboden uitgave', + receiptGeneratedWithAI: 'Mogelijke AI-gegenereerde bon', + receiptNotSmartScanned: 'Bon toegevoegd met de hand', + receiptRequired: 'Bonnetje vereist', + rter: 'Wachten op kaartkoppeling', + smartscanFailed: 'Scannen van bon is mislukt', + someTagLevelsRequired: 'Label verplicht', + tagOutOfPolicy: 'Label niet meer geldig', + overLimitAttendee: 'Boven persoonslimiet', + customRules: 'Overtreding 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 0d14cbf4d173..f371a0d94931 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -294,6 +294,7 @@ const translations: TranslationDeepObject = { billable: 'Fakturowalne', nonBillable: 'Nierozliczalne', tag: 'Tag', + violations: 'Naruszenia', receipt: 'Paragon', verified: 'Zweryfikowano', replace: 'Zastąp', @@ -9089,6 +9090,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 osobę składającą wniosek', }, }, genericErrorPage: { @@ -9759,6 +9761,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: 'Element fakturowany już nieaktualny', + cashExpenseWithNoReceipt: 'Wymagany paragon', + categoryOutOfPolicy: 'Kategoria jest już nieprawidłowa', + companyCardRequired: 'Wymagana karta firmowa', + conversionSurcharge: 'Zastosowano dopłatę za przewalutowanie', + customUnitOutOfPolicy: 'Stawka nie jest prawidłowa dla tego workspace’a', + customUnitRateOutOfDateRange: 'Stawka poza obowiązującymi datami', + duplicatedTransaction: 'Potencjalny duplikat', + fieldRequired: 'Pole raportu jest wymagane', + futureDate: 'Data z przyszłości jest niedozwolona', + hold: 'Wydatek wstrzymany', + inactiveVendor: 'Dostawca już nie jest ważny', + increasedDistance: 'Dystans przekracza trasę', + invoiceMarkup: 'Faktura oznaczona', + itemizedReceiptRequired: 'Wymagany paragon z wyszczególnieniem pozycji', + maxAge: 'Wydatek zbyt stary', + missingAttendees: 'Wymagani uczestnicy', + missingCategory: 'Brak kategorii', + missingComment: 'Opis wymagany', + missingTag: 'Brakujący tag', + modifiedAmount: 'Kwota zmieniona', + modifiedDate: 'Data modyfikacji', + noRoute: 'Brak poprawnej trasy', + nonExpensiworksExpense: 'Wydatek spoza Expensiworks', + overAutoApprovalLimit: 'Powyżej limitu automatycznej akceptacji', + overCategoryLimit: 'Poza limitem kategorii', + overLimit: 'Poza limitem', + overTripLimit: 'Przekroczono limit podróży', + perDayLimit: 'Przekroczono dzienny limit', + 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 jest wymagany', + tagOutOfPolicy: 'Tag jest już nieważny', + overLimitAttendee: 'Przekroczono limit osób', + customRules: 'Naruszenie niestandardowej reguły', + taxAmountChanged: 'Kwota podatku zmodyfikowana', + taxOutOfPolicy: 'Stawka podatku nie jest już ważna', + taxRateChanged: 'Zmieniono 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 52690e34b069..5aa10762f79c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -294,6 +294,7 @@ const translations: TranslationDeepObject = { billable: 'Faturável', nonBillable: 'Não faturável', tag: 'Etiqueta', + violations: 'Violações', receipt: 'Recibo', verified: 'Verificado', replace: 'Substituir', @@ -9107,6 +9108,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: { @@ -9782,6 +9784,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 são 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: 'Sobretaxa 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 marcada', + itemizedReceiptRequired: 'Recibo detalhado obrigatório', + maxAge: 'Despesa muito antiga', + 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: 'Recibo possivelmente gerado por IA', + receiptNotSmartScanned: 'Recibo adicionado manualmente', + receiptRequired: 'Recibo obrigatório', + rter: 'Aguardando correspondência de cartão', + smartscanFailed: 'Falha ao digitalizar o recibo', + someTagLevelsRequired: 'Etiqueta 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: 'Taxa 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 59339478e05d..9ab3dc8476ef 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -294,6 +294,7 @@ const translations: TranslationDeepObject = { billable: '可计费', nonBillable: '不可计费', tag: '标签', + violations: '违规', receipt: '收据', verified: '已验证', replace: '替换', @@ -8783,6 +8784,7 @@ ${reportName}`, topSpenders: '最高消费者', topCategories: '热门类别', topMerchants: '热门商家', + violationsBySubmitter: '提交人违规', }, }, genericErrorPage: { @@ -9446,6 +9448,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 f0ec532e9e32..43f1852922cd 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4974,6 +4974,7 @@ export { isHoldAction, isWhisperAction, isSubmittedAction, + isSubmittedAndClosedAction, isDynamicExternalWorkflowSubmitAction, isMarkAsClosedAction, isForwardedAction, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 4772dc07a810..b5e8fabf9147 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -121,6 +121,7 @@ import Parser from './Parser'; import {getLoginByAccountID, temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils'; import { arePaymentsEnabled, + arePolicyRulesEnabled, canSendInvoice, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, @@ -143,6 +144,8 @@ import { isMoneyRequestAction, isReportActionVisible, isResolvedActionableWhisper, + isSubmittedAction, + isSubmittedAndClosedAction, isWhisperActionTargetedToOthers, } from './ReportActionsUtils'; import {deprecatedGetReportName} from './ReportNameUtils'; @@ -437,6 +440,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 { @@ -566,6 +570,7 @@ const SEARCH_TYPE_MENU_ICON_NAMES = [ 'CreditCardHourglass', 'Bank', 'User', + 'UserEye', 'Folder', 'Basket', 'CalendarSolid', @@ -1049,6 +1054,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', @@ -1121,6 +1158,7 @@ function getSuggestedSearchesVisibility( let shouldShowTopSpendersSuggestion = false; let shouldShowTopCategoriesSuggestion = false; let shouldShowTopMerchantsSuggestion = false; + let shouldShowViolationsBySubmitterSuggestion = false; let hasGroupPoliciesWithExpenseChat = false; let shouldShowSpendOverTimeSuggestion = false; const topSpendersPolicyIDs: string[] = []; @@ -1168,6 +1206,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; @@ -1184,6 +1223,7 @@ function getSuggestedSearchesVisibility( } shouldShowTopCategoriesSuggestion ||= isEligibleForTopCategoriesSuggestion; shouldShowTopMerchantsSuggestion ||= isEligibleForTopMerchantsSuggestion; + shouldShowViolationsBySubmitterSuggestion ||= isEligibleForViolationsBySubmitterSuggestion; hasGroupPoliciesWithExpenseChat ||= isGroupPolicyEligible && !!policy.isPolicyExpenseChatEnabled && @@ -1208,6 +1248,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, }, hasGroupPoliciesWithExpenseChat, @@ -4529,6 +4570,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: @@ -4918,6 +4961,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) { @@ -5034,6 +5078,64 @@ 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. + */ +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}, @@ -6168,6 +6270,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, @@ -6201,6 +6304,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, @@ -6311,6 +6415,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. @@ -6426,6 +6537,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; } @@ -6693,6 +6812,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, @@ -6826,6 +6946,7 @@ export { getWithdrawalStatusDisplayText, getColumnsToShow, getHasOptions, + getSubmittedViolationsForTransaction, getSettlementStatus, getSettlementStatusBadgeProps, getSearchColumnTranslationKey, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 2540fe50721e..5a2d86519bfb 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2005,6 +2005,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 443eec3ca5a1..75c356be9b5e 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 5b219ae44cc2..abb813f42641 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -9189,6 +9189,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}`; @@ -9707,6 +9815,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', () => { @@ -9880,6 +10054,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 = { @@ -9976,6 +10265,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 = { @@ -11672,6 +11967,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', () => ({ From 99facc99f59983abf36a0ec43782ba148a6e03d4 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Mon, 17 Aug 2026 23:52:10 +0700 Subject: [PATCH 2/9] fix: Violations by submitter row is highlighted when Submitted violation filter is not selected --- tests/unit/Search/SearchQueryUtilsTest.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 9ec0f3851eec..24cbfe33d153 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -1944,6 +1944,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'); From b55912441fe6be931bcf3e805235e9c16df42d71 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 18 Aug 2026 00:12:32 +0700 Subject: [PATCH 3/9] add violations columns to required list --- src/CONST/index.ts | 2 +- src/pages/Search/SearchColumnsPage.tsx | 28 ++++++++++++- tests/unit/Search/SearchUIUtilsTest.ts | 39 +++++++++++++++++++ .../ColumnAvailabilityTest.ts.snap | 1 + 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 678dc42d67d9..6740c1bde395 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7042,7 +7042,7 @@ const CONST = { TAG: {column: this.TABLE_COLUMNS.TAG, search: true, reportView: true}, VIOLATIONS: { column: this.TABLE_COLUMNS.VIOLATIONS, - search: false, + search: true, reportView: false, }, TAG_GL_CODE: { diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index eb4f90065b5e..13651b5cfaa9 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -14,6 +14,21 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import React from 'react'; +/** Inserts `columnId` immediately before the total-amount column, or appends it if that column is missing. */ +function insertColumnBeforeTotalAmount(columns: SearchCustomColumnIds[], columnId: SearchCustomColumnIds) { + if (columns.includes(columnId)) { + return; + } + + const totalAmountIndex = columns.indexOf(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); + if (totalAmountIndex === -1) { + columns.push(columnId); + return; + } + + columns.splice(totalAmountIndex, 0, columnId); +} + function SearchColumnsPage() { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); @@ -23,9 +38,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 +60,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/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 74cff4c50078..8a0f9710bcb8 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -10737,6 +10737,45 @@ describe('SearchUIUtils', () => { 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 = { 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", From 71d8fb27eb2796c799b51291d43846728d6ae439 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 18 Aug 2026 00:20:16 +0700 Subject: [PATCH 4/9] fix tst --- src/libs/SearchQueryUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d76856248cf2..d01b7fa76491 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -582,7 +582,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) => { From a9c9a16604d6add4659e5351ec7d30849ecc3d7b Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 18 Aug 2026 12:05:14 +0700 Subject: [PATCH 5/9] address bot review comment --- src/hooks/useSearchBulkActions.ts | 26 ++++- src/hooks/useSearchTypeMenuSections.ts | 6 + src/libs/SearchQueryUtils.ts | 10 ++ src/libs/SearchUIUtils.ts | 41 +++++-- src/pages/Search/SearchColumnsPage.tsx | 17 +-- tests/unit/Search/SearchQueryUtilsTest.ts | 25 +++++ tests/unit/Search/SearchUIUtilsTest.ts | 64 +++++++++++ .../hooks/useSearchBulkActionsExportTest.ts | 104 ++++++++++++++++-- 8 files changed, 258 insertions(+), 35 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 288785aa148e..6d02994cc98e 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -65,9 +65,25 @@ import { isSelfDM, shouldShowMarkAsDone, } from '@libs/ReportUtils'; -import {buildSearchQueryJSON, buildSearchQueryString, getFilterFromQuery, isDefaultExpensesQuery, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; +import { + buildSearchQueryJSON, + buildSearchQueryString, + getFilterFromQuery, + isDefaultExpensesQuery, + queryHasSubmittedViolationFilter, + serializeQueryJSONForBackend, +} from '@libs/SearchQueryUtils'; import refreshSearchAfterReportAction from '@libs/SearchRefreshUtils'; -import {getColumnsToShow, getSearchColumnTranslationKey, getSelectedGroupFilterEntry, getValidGroupBy, isGroupEntry, navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; +import { + getColumnsToShow, + getSearchColumnTranslationKey, + getSelectedGroupFilterEntry, + getValidGroupBy, + insertColumnBeforeTotalAmount, + isGroupEntry, + navigateToSearchRHP, + shouldShowDeleteOption, +} from '@libs/SearchUIUtils'; import showConfirmModalAfterMoreMenuDismiss from '@libs/showConfirmModalAfterMoreMenuDismiss'; import playSound, {SOUNDS} from '@libs/Sound'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; @@ -895,6 +911,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const expenseColumns: SearchColumnType[] = (visibleColumns ?? []).filter((column) => 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); + } + console.log('columnsToExport', columnsToExport); } else { columnsToExport = getColumnsToShow({ currentAccountID: accountID, diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 5cb9b3f25ab5..d67c0160f225 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -78,6 +78,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). @@ -127,6 +131,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams draftTransactionIDs, isTrackIntentUser: isTrackIntentUser ?? false, hasReportAwaitingApproval, + policyCategories: allPolicyCategories, }), [ currentUserLoginAndAccountID?.email, @@ -141,6 +146,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams draftTransactionIDs, isTrackIntentUser, hasReportAwaitingApproval, + allPolicyCategories, ], ); diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d01b7fa76491..89cadd08c49d 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -458,6 +458,15 @@ 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 hasFilter = getFilterFromQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS); + return !hasFilter.isNegated && !!hasFilter.value?.includes(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); +} + /** * @private * Returns an updated filter value for some query filters. @@ -2651,6 +2660,7 @@ export { removeNegation, getFilterFormValues, getFilterFromQuery, + queryHasSubmittedViolationFilter, }; export type {BuildUserReadableQueryStringParams}; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 20cbe0bf1546..04ec72446628 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1140,6 +1140,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, @@ -1148,6 +1151,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; @@ -1209,7 +1213,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) && memberCount >= 2; + const isEligibleForViolationsBySubmitterSuggestion = + isGroupPolicyEligible && + (isAdmin || isAuditor) && + arePolicyRulesEnabled(policy, policy.id ? policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`] : undefined) && + memberCount >= 2; shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion; shouldShowPaySuggestion ||= isEligibleForPaySuggestion; @@ -4528,6 +4536,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: @@ -4864,6 +4889,7 @@ type TypeMenuSectionsParams = { draftTransactionIDs: string[] | undefined; isTrackIntentUser: boolean; hasReportAwaitingApproval?: boolean; + policyCategories?: OnyxCollection; }; function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuSection[] { @@ -4880,6 +4906,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS draftTransactionIDs, isTrackIntentUser, hasReportAwaitingApproval = false, + policyCategories, } = params; const typeMenuSections: SearchTypeMenuSection[] = []; @@ -4888,7 +4915,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; @@ -6651,13 +6678,8 @@ 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); - } + if (columns[CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS]) { + insertColumnBeforeTotalAmount(customResult, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); } return customResult; } @@ -7059,6 +7081,7 @@ export { getWithdrawalStatusOptions, getWithdrawalStatusDisplayText, getColumnsToShow, + insertColumnBeforeTotalAmount, getHasOptions, getSubmittedViolationsForTransaction, getSettlementStatus, diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index 13651b5cfaa9..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'; @@ -14,21 +14,6 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import React from 'react'; -/** Inserts `columnId` immediately before the total-amount column, or appends it if that column is missing. */ -function insertColumnBeforeTotalAmount(columns: SearchCustomColumnIds[], columnId: SearchCustomColumnIds) { - if (columns.includes(columnId)) { - return; - } - - const totalAmountIndex = columns.indexOf(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); - if (totalAmountIndex === -1) { - columns.push(columnId); - return; - } - - columns.splice(totalAmountIndex, 0, columnId); -} - function SearchColumnsPage() { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 24cbfe33d153..27ed13fab524 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, @@ -3815,6 +3816,30 @@ 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 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 e247da1e1af5..0022daf3bbfd 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -9869,6 +9869,70 @@ describe('SearchUIUtils', () => { 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({ diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index 34c7fd41280b..24b8c36fc565 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -185,14 +185,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, @@ -289,6 +302,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, @@ -1098,6 +1128,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], From 48e500796b0ba2ddb8eea28f6e2ec0ccd926cb2a Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 18 Aug 2026 12:11:20 +0700 Subject: [PATCH 6/9] remove log --- src/hooks/useSearchBulkActions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 6d02994cc98e..f504875795b5 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -916,7 +916,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (queryHasSubmittedViolationFilter(queryJSON)) { insertColumnBeforeTotalAmount(columnsToExport, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); } - console.log('columnsToExport', columnsToExport); } else { columnsToExport = getColumnsToShow({ currentAccountID: accountID, From 994a16c47baaee4ec2404adef5f2dba5275032a7 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 1 Sep 2026 00:25:23 +0700 Subject: [PATCH 7/9] prevent duplicate violations --- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/libs/SearchUIUtils.ts | 8 ++++++ tests/unit/Search/SearchUIUtilsTest.ts | 35 ++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index d3078702858b..49a3f142d28e 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10355,7 +10355,7 @@ const translations = { increasedDistance: 'Distance exceeds route', invoiceMarkup: 'Invoice marked up', itemizedReceiptRequired: 'Itemized receipt required', - maxAge: 'Expense too old', + maxAge: 'Date older than max expense age', missingAttendees: 'Attendees required', missingCategory: 'Missing category', missingComment: 'Description required', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1e5b22d8bd45..d13a10dc58f8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10412,7 +10412,7 @@ ${reportName}`, increasedDistance: 'La distancia supera la ruta', invoiceMarkup: 'Factura con recargo', itemizedReceiptRequired: 'Se requiere recibo detallado', - maxAge: 'Gasto demasiado antiguo', + maxAge: 'Fecha anterior a la antigüedad máxima del gasto', missingAttendees: 'Asistentes obligatorios', missingCategory: 'Falta la categoría', missingComment: 'Descripción obligatoria', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index c88cba6eb62c..041decb0768f 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -5278,6 +5278,9 @@ function getSubmittedViolationDisplayName(violationName: string, translate: Loca * 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) { @@ -5307,6 +5310,11 @@ function getSubmittedViolationsForTransaction(reportActions: OnyxTypes.ReportAct 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(', '); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 5d61c0cce29b..128015d0d283 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -12846,6 +12846,41 @@ describe('SearchUIUtils', () => { ); }); + 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: { From 4d59c4a166aa8fc6053a024eab320ba3fb38a296 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Tue, 1 Sep 2026 23:37:42 +0700 Subject: [PATCH 8/9] update translation --- src/languages/de.ts | 38 ++++++++++++++++++------------------ src/languages/el.ts | 30 ++++++++++++++-------------- src/languages/es.ts | 26 ++++++++++++------------- src/languages/fr.ts | 22 ++++++++++----------- src/languages/it.ts | 16 +++++++-------- src/languages/ja.ts | 42 ++++++++++++++++++++-------------------- src/languages/nl.ts | 42 ++++++++++++++++++++-------------------- src/languages/pl.ts | 42 ++++++++++++++++++++-------------------- src/languages/pt-BR.ts | 22 ++++++++++----------- src/languages/zh-hans.ts | 38 ++++++++++++++++++------------------ 10 files changed, 159 insertions(+), 159 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 0db09ac778c6..6dd924d2dd40 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9488,7 +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 nach Einreichender', + violationsBySubmitter: 'Verstöße durch Einreichende', }, }, genericErrorPage: { @@ -10200,39 +10200,39 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc categoryOutOfPolicy: 'Kategorie nicht mehr gültig', companyCardRequired: 'Firmenkarte erforderlich', conversionSurcharge: 'Umrechnungszuschlag angewendet', - customUnitOutOfPolicy: 'Wechselkurs für Arbeitsbereich ungültig', - customUnitRateOutOfDateRange: 'Satz außerhalb gültiger Daten', + customUnitOutOfPolicy: 'Satz für Arbeitsbereich ungültig', + customUnitRateOutOfDateRange: 'Kurs außerhalb der gültigen Daten', duplicatedTransaction: 'Möglicher Duplikat', - fieldRequired: 'Berichts­feld erforderlich', - futureDate: 'Zukünftiges Datum nicht erlaubt', - hold: 'Spesen zurückgestellt', + 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 kommentiert', - itemizedReceiptRequired: 'Detaillierte Quittung erforderlich', - maxAge: 'Ausgabe zu alt', + 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: 'Fehlender Tag', + missingTag: 'Fehlendes Tag', modifiedAmount: 'Betrag geändert', modifiedDate: 'Änderungsdatum', noRoute: 'Keine gültige Route', - nonExpensiworksExpense: 'Nicht-Expensiworks-Ausgabe', - overAutoApprovalLimit: 'Über dem Limit für automatische Genehmigung', - overCategoryLimit: 'Über Kategoriegrenze', - overLimit: 'Über dem Limit', - overTripLimit: 'Über dem Reiselimit', + 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öglicherweise KI-generierte Quittung', + receiptGeneratedWithAI: 'Mögliche KI-generierte Quittung', receiptNotSmartScanned: 'Beleg manuell hinzugefügt', receiptRequired: 'Beleg erforderlich', - rter: 'Warten auf Kartenabgleich', - smartscanFailed: 'Belegerfassung fehlgeschlagen', + rter: 'Kartenzuordnung ausstehend', + smartscanFailed: 'Belegscan fehlgeschlagen', someTagLevelsRequired: 'Tag erforderlich', tagOutOfPolicy: 'Tag nicht mehr gültig', - overLimitAttendee: 'Personenlimit überschritten', + overLimitAttendee: 'Über Personenlimit', customRules: 'Verstoß gegen benutzerdefinierte Regel', taxAmountChanged: 'Steuerbetrag geändert', taxOutOfPolicy: 'Steuersatz nicht mehr gültig', diff --git a/src/languages/el.ts b/src/languages/el.ts index 0f9d206509d6..348c63dc428d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -9414,7 +9414,7 @@ ${reportName}`, topSpenders: 'Κορυφαίοι δαπανώντες', topCategories: 'Κορυφαίες κατηγορίες', topMerchants: 'Κορυφαίοι έμποροι', - violationsBySubmitter: 'Παραβάσεις από τον αποστολέα', + violationsBySubmitter: 'Παραβάσεις από τον υποβάλλοντα', }, resultsAreLimited: 'Τα αποτελέσματα αναζήτησης είναι περιορισμένα.', viewResults: 'Προβολή αποτελεσμάτων', @@ -10415,7 +10415,7 @@ ${reportName}`, shortName: { allTagLevelsRequired: 'Απαιτούνται όλες οι ετικέτες', autoReportedRejectedExpense: 'Η δαπάνη απορρίφθηκε', - billableExpense: 'Χρεώσιμο μη έγκυρο πλέον', + billableExpense: 'Η χρέωση δεν ισχύει πλέον', cashExpenseWithNoReceipt: 'Απαιτείται απόδειξη', categoryOutOfPolicy: 'Η κατηγορία δεν είναι πλέον έγκυρη', companyCardRequired: 'Απαιτείται εταιρική κάρτα', @@ -10423,37 +10423,37 @@ ${reportName}`, customUnitOutOfPolicy: 'Μη έγκυρη χρέωση για τον χώρο εργασίας', customUnitRateOutOfDateRange: 'Χρέωση εκτός έγκυρων ημερομηνιών', duplicatedTransaction: 'Πιθανό διπλότυπο', - fieldRequired: 'Απαιτείται πεδίο αναφοράς', + fieldRequired: 'Απαραίτητο πεδίο αναφοράς', futureDate: 'Δεν επιτρέπεται μελλοντική ημερομηνία', hold: 'Δαπάνη σε αναμονή', inactiveVendor: 'Ο προμηθευτής δεν είναι πλέον έγκυρος', increasedDistance: 'Η απόσταση υπερβαίνει τη διαδρομή', - invoiceMarkup: 'Το τιμολόγιο επισημάνθηκε', + invoiceMarkup: 'Το τιμολόγιο σημειώθηκε', itemizedReceiptRequired: 'Απαιτείται αναλυτική απόδειξη', - maxAge: 'Η δαπάνη είναι πολύ παλιά', - missingAttendees: 'Υποχρεωτικοί συμμετέχοντες', + maxAge: 'Η ημερομηνία είναι παλαιότερη από το μέγιστο επιτρεπτό όριο δαπάνης', + missingAttendees: 'Απαιτούνται συμμετέχοντες', missingCategory: 'Λείπει κατηγορία', missingComment: 'Απαιτείται περιγραφή', missingTag: 'Ελλιπής ετικέτα', modifiedAmount: 'Το ποσό τροποποιήθηκε', modifiedDate: 'Ημερομηνία τροποποίησης', - noRoute: 'Δεν υπάρχει έγκυρη διαδρομή', - nonExpensiworksExpense: 'Δαπάνη εκτός Expensiworks', - overAutoApprovalLimit: 'Πέρα από το όριο αυτόματης έγκρισης', - overCategoryLimit: 'Πάνω από το όριο κατηγορίας', - overLimit: 'Πέρα από το όριο', + noRoute: 'Μη έγκυρη διαδρομή', + nonExpensiworksExpense: 'Έξοδο εκτός Expensiworks', + overAutoApprovalLimit: 'Πάνω από το όριο αυτόματης έγκρισης', + overCategoryLimit: 'Πέρα από το όριο κατηγορίας', + overLimit: 'Υπέρβαση ορίου', overTripLimit: 'Υπέρβαση ορίου ταξιδιού', perDayLimit: 'Υπέρβαση ημερήσιου ορίου', prohibitedExpense: 'Απαγορευμένη δαπάνη', - receiptGeneratedWithAI: 'Πιθανή απόδειξη που δημιουργήθηκε με τεχνητή νοημοσύνη', + receiptGeneratedWithAI: 'Πιθανή απόδειξη που δημιουργήθηκε από AI', receiptNotSmartScanned: 'Η απόδειξη προστέθηκε χειροκίνητα', receiptRequired: 'Απαιτείται απόδειξη', rter: 'Αναμονή αντιστοίχισης κάρτας', - smartscanFailed: 'Η σάρωση της απόδειξης απέτυχε', + smartscanFailed: 'Η σάρωση απόδειξης απέτυχε', someTagLevelsRequired: 'Απαιτείται ετικέτα', tagOutOfPolicy: 'Η ετικέτα δεν είναι πλέον έγκυρη', - overLimitAttendee: 'Πέρα από το όριο ατόμων', - customRules: 'Παράβαση προσαρμοσμένου κανόνα', + overLimitAttendee: 'Υπέρβαση ορίου ατόμων', + customRules: 'Παραβίαση προσαρμοσμένου κανόνα', taxAmountChanged: 'Το ποσό φόρου τροποποιήθηκε', taxOutOfPolicy: 'Ο φορολογικός συντελεστής δεν είναι πλέον έγκυρος', taxRateChanged: 'Ο φορολογικός συντελεστής τροποποιήθηκε', diff --git a/src/languages/es.ts b/src/languages/es.ts index b69ece220249..e7af69bf8ec8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10402,13 +10402,13 @@ ${reportName}`, shortName: { allTagLevelsRequired: 'Todas las etiquetas obligatorias', autoReportedRejectedExpense: 'Gasto rechazado', - billableExpense: 'Facturable ya no es válido', + billableExpense: 'Refacturable ya no válido', cashExpenseWithNoReceipt: 'Recibo obligatorio', categoryOutOfPolicy: 'La categoría ya no es válida', - companyCardRequired: 'Se requiere tarjeta de la empresa', - conversionSurcharge: 'Recargo por conversión aplicado', + 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 fechas válidas', + customUnitRateOutOfDateRange: 'Tasa fuera de las fechas válidas', duplicatedTransaction: 'Posible duplicado', fieldRequired: 'Campo de informe obligatorio', futureDate: 'No se permite una fecha futura', @@ -10416,35 +10416,35 @@ ${reportName}`, inactiveVendor: 'El proveedor ya no es válido', increasedDistance: 'La distancia supera la ruta', invoiceMarkup: 'Factura con recargo', - itemizedReceiptRequired: 'Se requiere recibo detallado', + itemizedReceiptRequired: 'Se requiere recibo desglosado', maxAge: 'Fecha anterior a la antigüedad máxima del gasto', missingAttendees: 'Asistentes obligatorios', - missingCategory: 'Falta la categoría', + 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 fuera de Expensiworks', - overAutoApprovalLimit: 'Límite de autoaprobación superado', + nonExpensiworksExpense: 'Gasto ajeno a Expensiworks', + overAutoApprovalLimit: 'Límite de aprobación automática superado', overCategoryLimit: 'Límite de categoría superado', - overLimit: 'Límite superado', + overLimit: 'Límite excedido', overTripLimit: 'Límite de viaje superado', - perDayLimit: 'Por encima del límite diario', + 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 coincidencia de tarjeta', + 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: 'Infracción de regla personalizada', + 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 impuestos', + taxRequired: 'Falta la tasa de impuesto', }, }, reportViolations: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 55affdf69cbc..9b90c078e66b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10228,8 +10228,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e billableExpense: 'Facturable n’est plus valide', cashExpenseWithNoReceipt: 'Reçu obligatoire', categoryOutOfPolicy: 'Catégorie n’est plus valide', - companyCardRequired: 'Carte de société requise', - conversionSurcharge: 'Surtaxe de conversion appliquée', + 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', @@ -10240,33 +10240,33 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e increasedDistance: 'La distance dépasse l’itinéraire', invoiceMarkup: 'Facture annotée', itemizedReceiptRequired: 'Reçu détaillé requis', - maxAge: 'Dépense trop ancienne', - missingAttendees: 'Participants 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: 'Aucune route valide', + 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-delà de la limite quotidienne', + 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 rapprochement de carte', + 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: 'Limite de personnes dépassée', + overLimitAttendee: 'Nombre de personnes dépassé', customRules: 'Violation de règle personnalisée', - taxAmountChanged: 'Montant de taxe modifié', - taxOutOfPolicy: 'Taux d’imposition n’est plus valide', - taxRateChanged: 'Taux de taxe modifié', + taxAmountChanged: 'Montant de la taxe modifié', + taxOutOfPolicy: 'Taux de taxe n’est plus valide', + taxRateChanged: 'Taux d’imposition modifié', taxRequired: 'Taux de taxe manquant', }, }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 5459bfa68512..049211525151 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10163,8 +10163,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, billableExpense: 'Fatturabile non più valido', cashExpenseWithNoReceipt: 'Ricevuta obbligatoria', categoryOutOfPolicy: 'Categoria non più valida', - companyCardRequired: 'È richiesta una carta aziendale', - conversionSurcharge: 'Maggiorazione di conversione applicata', + 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', @@ -10173,9 +10173,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, hold: 'Spesa in sospeso', inactiveVendor: 'Fornitore non più valido', increasedDistance: 'La distanza supera il percorso', - invoiceMarkup: 'Fattura con maggiorazione', - itemizedReceiptRequired: 'Ricevuta dettagliata richiesta', - maxAge: 'Spesa troppo vecchia', + 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', @@ -10185,10 +10185,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, noRoute: 'Nessun percorso valido', nonExpensiworksExpense: 'Spesa non Expensiworks', overAutoApprovalLimit: 'Oltre il limite di approvazione automatica', - overCategoryLimit: 'Limite categoria superato', + overCategoryLimit: 'Oltre il limite di categoria', overLimit: 'Oltre il limite', - overTripLimit: 'Oltre il limite di viaggio', - perDayLimit: 'Oltre il limite giornaliero', + overTripLimit: 'Oltre il limite del viaggio', + perDayLimit: 'Supera il limite giornaliero', prohibitedExpense: 'Spesa vietata', receiptGeneratedWithAI: "Possibile ricevuta generata dall'IA", receiptNotSmartScanned: 'Ricevuta aggiunta manualmente', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a5722fa6bfff..8b6131bdca34 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10014,43 +10014,43 @@ ${reportName}`, shortName: { allTagLevelsRequired: 'すべてのタグが必須です', autoReportedRejectedExpense: '経費が却下されました', - billableExpense: '請求対象は無効になりました', + billableExpense: '請求可能区分は無効になりました', cashExpenseWithNoReceipt: '領収書が必要です', - categoryOutOfPolicy: 'カテゴリは無効になりました', + categoryOutOfPolicy: 'カテゴリは無効になっています', companyCardRequired: '会社カードが必要です', conversionSurcharge: '両替手数料が適用されました', - customUnitOutOfPolicy: 'ワークスペースでは有効なレートではありません', - customUnitRateOutOfDateRange: '有効期間外のレートです', + customUnitOutOfPolicy: 'ワークスペースに有効なレートではありません', + customUnitRateOutOfDateRange: '有効な日付期間外のレートです', duplicatedTransaction: '重複の可能性', - fieldRequired: 'レポートフィールドは必須です', + fieldRequired: 'レポートの必須項目です', futureDate: '未来の日付は使用できません', hold: '保留中の経費', - inactiveVendor: 'ベンダーが無効になりました', + inactiveVendor: '取引先は有効ではありません', increasedDistance: '距離がルートを超えています', - invoiceMarkup: '請求書がマークアップされました', + invoiceMarkup: '請求書にマークアップを適用しました', itemizedReceiptRequired: '明細付き領収書が必要です', - maxAge: '経費が古すぎます', - missingAttendees: '参加者必須', - missingCategory: 'カテゴリが未設定です', + maxAge: '経費の最長期限を過ぎた日付です', + missingAttendees: '参加者は必須です', + missingCategory: 'カテゴリが未選択です', missingComment: '説明が必要です', missingTag: 'タグがありません', - modifiedAmount: '金額が変更されました', + modifiedAmount: '金額を変更しました', modifiedDate: '更新日', noRoute: '有効なルートがありません', - nonExpensiworksExpense: 'Expensiworks 外の経費', - overAutoApprovalLimit: '自動承認上限超過', - overCategoryLimit: 'カテゴリ上限超過', - overLimit: '限度超過', - overTripLimit: '出張上限を超過', + nonExpensiworksExpense: 'Expensiworks 以外の経費', + overAutoApprovalLimit: '自動承認限度額を超過', + overCategoryLimit: 'カテゴリ上限を超えています', + overLimit: '上限超過', + overTripLimit: '出張上限超過', perDayLimit: '1 日あたりの上限超過', prohibitedExpense: '禁止経費', - receiptGeneratedWithAI: 'AI 生成の可能性があるレシート', - receiptNotSmartScanned: 'レシートを手動で追加しました', + receiptGeneratedWithAI: 'AI が生成した可能性のあるレシート', + receiptNotSmartScanned: '手動でレシートを追加しました', receiptRequired: '領収書が必要です', - rter: 'カードの照合待ち', + rter: 'カードとの照合待ち', smartscanFailed: 'レシートのスキャンに失敗しました', - someTagLevelsRequired: 'タグが必要です', - tagOutOfPolicy: 'タグは有効ではありません', + someTagLevelsRequired: 'タグが必須です', + tagOutOfPolicy: 'タグは無効です', overLimitAttendee: '人数上限超過', customRules: 'カスタムルール違反', taxAmountChanged: '税額が変更されました', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a0fa2d58f888..a919b81f359f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10120,47 +10120,47 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, 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 vereist', - autoReportedRejectedExpense: 'Declaratie afgewezen', - billableExpense: 'Declarabel niet langer geldig', - cashExpenseWithNoReceipt: 'Bonnetje vereist', + allTagLevelsRequired: 'Alle labels verplicht', + autoReportedRejectedExpense: 'Uitgave afgekeurd', + billableExpense: 'Doorbelastbaar niet langer geldig', + cashExpenseWithNoReceipt: 'Bon nodig', categoryOutOfPolicy: 'Categorie niet meer geldig', companyCardRequired: 'Bedrijfskaart vereist', - conversionSurcharge: 'Omkostentoeslag voor conversie toegepast', - customUnitOutOfPolicy: 'Tarief niet geldig voor workspace', + 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: 'Uitgave in de wacht', + hold: 'Declaratie in de wacht', inactiveVendor: 'Leverancier niet meer geldig', increasedDistance: 'Afstand overschrijdt route', - invoiceMarkup: 'Factuur gewijzigd', + invoiceMarkup: 'Factuur gecorrigeerd', itemizedReceiptRequired: 'Gespecificeerde bon vereist', - maxAge: 'Declaratie te oud', + maxAge: 'Datum ouder dan maximale onkostendatum', missingAttendees: 'Deelnemers verplicht', missingCategory: 'Ontbrekende categorie', - missingComment: 'Beschrijving verplicht', + missingComment: 'Beschrijving vereist', missingTag: 'Ontbrekende tag', modifiedAmount: 'Bedrag aangepast', - modifiedDate: 'Datum gewijzigd', + modifiedDate: 'Wijzigingsdatum', noRoute: 'Geen geldige route', nonExpensiworksExpense: 'Niet-Expensiworks-uitgave', overAutoApprovalLimit: 'Boven automatische goedkeuringslimiet', - overCategoryLimit: 'Bovengrens categorie overschreden', + overCategoryLimit: 'Bovencategorie-limiet', overLimit: 'Bovengrens overschreden', - overTripLimit: 'Boven reislimiet', + overTripLimit: 'Bovengrens per rit', perDayLimit: 'Boven daglimiet', prohibitedExpense: 'Verboden uitgave', receiptGeneratedWithAI: 'Mogelijke AI-gegenereerde bon', - receiptNotSmartScanned: 'Bon toegevoegd met de hand', - receiptRequired: 'Bonnetje vereist', - rter: 'Wachten op kaartkoppeling', - smartscanFailed: 'Scannen van bon is mislukt', - someTagLevelsRequired: 'Label verplicht', - tagOutOfPolicy: 'Label niet meer geldig', - overLimitAttendee: 'Boven persoonslimiet', - customRules: 'Overtreding van aangepaste regel', + 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', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1fb6e314a8bc..b678fbc601cf 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9440,7 +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 osobę składającą wniosek', + violationsBySubmitter: 'Naruszenia przez zgłaszającego', }, }, genericErrorPage: { @@ -10143,48 +10143,48 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, shortName: { allTagLevelsRequired: 'Wymagane są wszystkie tagi', autoReportedRejectedExpense: 'Wydatek odrzucony', - billableExpense: 'Element fakturowany już nieaktualny', + billableExpense: 'Opcja rozliczalne nie jest już dostępna', cashExpenseWithNoReceipt: 'Wymagany paragon', - categoryOutOfPolicy: 'Kategoria jest już nieprawidłowa', + categoryOutOfPolicy: 'Kategoria już nieaktualna', companyCardRequired: 'Wymagana karta firmowa', - conversionSurcharge: 'Zastosowano dopłatę za przewalutowanie', - customUnitOutOfPolicy: 'Stawka nie jest prawidłowa dla tego workspace’a', + 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: 'Data z przyszłości jest niedozwolona', + futureDate: 'Przyszła data jest niedozwolona', hold: 'Wydatek wstrzymany', - inactiveVendor: 'Dostawca już nie jest ważny', + inactiveVendor: 'Dostawca nie jest już prawidłowy', increasedDistance: 'Dystans przekracza trasę', invoiceMarkup: 'Faktura oznaczona', - itemizedReceiptRequired: 'Wymagany paragon z wyszczególnieniem pozycji', - maxAge: 'Wydatek zbyt stary', + itemizedReceiptRequired: 'Wymagany rachunek z wyszczególnieniem pozycjii', + maxAge: 'Data starsza niż maksymalny wiek wydatku', missingAttendees: 'Wymagani uczestnicy', missingCategory: 'Brak kategorii', - missingComment: 'Opis wymagany', + missingComment: 'Wymagany opis', missingTag: 'Brakujący tag', - modifiedAmount: 'Kwota zmieniona', + modifiedAmount: 'Kwota zmodyfikowana', modifiedDate: 'Data modyfikacji', - noRoute: 'Brak poprawnej trasy', + noRoute: 'Brak prawidłowej trasy', nonExpensiworksExpense: 'Wydatek spoza Expensiworks', overAutoApprovalLimit: 'Powyżej limitu automatycznej akceptacji', - overCategoryLimit: 'Poza limitem kategorii', - overLimit: 'Poza limitem', - overTripLimit: 'Przekroczono limit podróży', - perDayLimit: 'Przekroczono dzienny limit', + 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 jest wymagany', - tagOutOfPolicy: 'Tag jest już nieważny', + someTagLevelsRequired: 'Tag wymagany', + tagOutOfPolicy: 'Tag nie jest już ważny', overLimitAttendee: 'Przekroczono limit osób', - customRules: 'Naruszenie niestandardowej reguły', - taxAmountChanged: 'Kwota podatku zmodyfikowana', + customRules: 'Naruszenie reguły niestandardowej', + taxAmountChanged: 'Zmodyfikowano kwotę podatku', taxOutOfPolicy: 'Stawka podatku nie jest już ważna', - taxRateChanged: 'Zmieniono stawkę podatku', + taxRateChanged: 'Zmodyfikowano stawkę podatku', taxRequired: 'Brak stawki podatku', }, }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ff86b255d4f0..7be740845235 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10120,24 +10120,24 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, 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 são obrigatórias', + 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: 'Sobretaxa de conversão aplicada', + 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', + 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 marcada', - itemizedReceiptRequired: 'Recibo detalhado obrigatório', - maxAge: 'Despesa muito antiga', + 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', @@ -10152,19 +10152,19 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, overTripLimit: 'Acima do limite da viagem', perDayLimit: 'Acima do limite diário', prohibitedExpense: 'Despesa proibida', - receiptGeneratedWithAI: 'Recibo possivelmente gerado por IA', + receiptGeneratedWithAI: 'Possível recibo gerado por IA', receiptNotSmartScanned: 'Recibo adicionado manualmente', receiptRequired: 'Recibo obrigatório', - rter: 'Aguardando correspondência de cartão', - smartscanFailed: 'Falha ao digitalizar o recibo', - someTagLevelsRequired: 'Etiqueta obrigatória', + 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: 'Taxa de imposto ausente', + taxRequired: 'Alíquota de imposto ausente', }, }, reportViolations: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 26873b98f078..0d5681adf938 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9757,47 +9757,47 @@ ${reportName}`, shortName: { allTagLevelsRequired: '所有标签为必填项', autoReportedRejectedExpense: '报销已被拒绝', - billableExpense: '“可计费”不再有效', - cashExpenseWithNoReceipt: '收据必填', - categoryOutOfPolicy: '类别不再有效', + billableExpense: '可计费已不再有效', + cashExpenseWithNoReceipt: '需要收据', + categoryOutOfPolicy: '类别已失效', companyCardRequired: '需要公司卡', conversionSurcharge: '已收取兑换附加费', - customUnitOutOfPolicy: '费率对该工作区无效', - customUnitRateOutOfDateRange: '费率超出有效日期范围', + customUnitOutOfPolicy: '此费率对该工作区无效', + customUnitRateOutOfDateRange: '费率超出有效日期', duplicatedTransaction: '可能重复', fieldRequired: '报表字段为必填项', - futureDate: '不允许使用将来的日期', - hold: '报销暂挂', + futureDate: '不允许未来日期', + hold: '报销已暂挂', inactiveVendor: '供应商不再有效', - increasedDistance: '距离超出路线范围', - invoiceMarkup: '发票已标注', - itemizedReceiptRequired: '需要逐项明细收据', - maxAge: '报销过旧', - missingAttendees: '需要出席人员', + increasedDistance: '距离超过路线', + invoiceMarkup: '发票已标记加价', + itemizedReceiptRequired: '需要项目明细收据', + maxAge: '日期早于报销允许的最长期限', + missingAttendees: '必需参加者', missingCategory: '缺少类别', - missingComment: '描述为必填项', + missingComment: '必须填写说明', missingTag: '缺少标签', modifiedAmount: '金额已修改', modifiedDate: '修改日期', noRoute: '无效路由', nonExpensiworksExpense: '非 Expensiworks 报销', - overAutoApprovalLimit: '超出自动审批限额', + overAutoApprovalLimit: '超出自动批准限额', overCategoryLimit: '超出类别限额', overLimit: '超出限额', overTripLimit: '超出行程限额', perDayLimit: '超出每日限额', prohibitedExpense: '禁止报销费用', - receiptGeneratedWithAI: '可能为 AI 生成的收据', + receiptGeneratedWithAI: '可能由 AI 生成的收据', receiptNotSmartScanned: '已手动添加收据', - receiptRequired: '收据必填', + receiptRequired: '需要收据', rter: '等待卡片匹配', smartscanFailed: '收据扫描失败', - someTagLevelsRequired: '需要标签', - tagOutOfPolicy: '标签不再有效', + someTagLevelsRequired: '必须填写标签', + tagOutOfPolicy: '标签已失效', overLimitAttendee: '超出人数上限', customRules: '自定义规则违规', taxAmountChanged: '税额已修改', - taxOutOfPolicy: '税率不再有效', + taxOutOfPolicy: '税率已失效', taxRateChanged: '税率已修改', taxRequired: '缺少税率', }, From 42e135bb226725bcf0c803a624c7a2bb70a438ac Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Wed, 2 Sep 2026 00:23:58 +0700 Subject: [PATCH 9/9] update comment --- .../TransactionItemRow/TransactionItemRowWide.tsx | 3 +-- src/libs/SearchQueryUtils.ts | 10 ++++++++-- tests/unit/Search/SearchQueryUtilsTest.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 15f552765554..67f578d34c43 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -157,7 +157,6 @@ 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) { @@ -216,7 +215,7 @@ function TransactionItemRowWide({ key={column} style={[StyleUtils.getReportTableColumnStyles(CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS)]} > - + ); case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 68ce1f0b8a13..2861fb35b26a 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -462,8 +462,14 @@ function getFilterFromQuery(queryJSON: SearchQueryJSON | undefined, filterKey: S * Grouped CSV export uses this so Violations is included even when the query has no saved `columns`. */ function queryHasSubmittedViolationFilter(queryJSON: SearchQueryJSON | undefined): boolean { - const hasFilter = getFilterFromQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS); - return !hasFilter.isNegated && !!hasFilter.value?.includes(CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION); + 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), + ); } /** diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index a294bf4b6ee7..fb757dd45cd8 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -3849,6 +3849,18 @@ describe('SearchQueryUtils', () => { 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');