diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 79a6c40c91e08..bf41f03c97e53 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5732,6 +5732,11 @@ const CONST = { WARNING: 'warning', }, + /** + * Constant for prefix of violations. + */ + VIOLATIONS_PREFIX: 'violations.', + /** * Constants with different types for the modifiedAmount violation */ @@ -5786,6 +5791,7 @@ const CONST = { RECEIPT_GENERATED_WITH_AI: 'receiptGeneratedWithAI', OVER_TRIP_LIMIT: 'overTripLimit', COMPANY_CARD_REQUIRED: 'companyCardRequired', + MISSING_ATTENDEES: 'missingAttendees', }, RTER_VIOLATION_TYPES: { BROKEN_CARD_CONNECTION: 'brokenCardConnection', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7b92bc292b9b0..00b4a20bc124e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2007,6 +2007,10 @@ const ROUTES = { route: 'workspaces/:policyID/category/:categoryName/require-receipts-over', getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/category/${encodeURIComponent(categoryName)}/require-receipts-over` as const, }, + WORKSPACE_CATEGORY_REQUIRED_FIELDS: { + route: 'workspaces/:policyID/category/:categoryName/required-fields', + getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/category/${encodeURIComponent(categoryName)}/required-fields` as const, + }, WORKSPACE_CATEGORY_APPROVER: { route: 'workspaces/:policyID/category/:categoryName/approver', getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/category/${encodeURIComponent(categoryName)}/approver` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 989a9ae3fe92d..2a214d23e2d6a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -690,6 +690,7 @@ const SCREENS = { CATEGORY_DESCRIPTION_HINT: 'Category_Description_Hint', CATEGORY_APPROVER: 'Category_Approver', CATEGORY_REQUIRE_RECEIPTS_OVER: 'Category_Require_Receipts_Over', + CATEGORY_REQUIRED_FIELDS: 'Category_Required_Fields', CATEGORIES_SETTINGS: 'Categories_Settings', CATEGORIES_IMPORT: 'Categories_Import', CATEGORIES_IMPORTED: 'Categories_Imported', diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 4da78c1ab8189..ecc196fb38688 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -30,6 +30,7 @@ import { setMoneyRequestTaxRate, setSplitShares, } from '@libs/actions/IOU'; +import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import {convertToBackendAmount, convertToDisplayString, convertToDisplayStringWithoutCurrency, getCurrencyDecimals} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -433,11 +434,20 @@ function MoneyRequestConfirmationList({ setFormError('iou.receiptScanningFailed'); return; } - // reset the form error whenever the screen gains or loses focus - setFormError(''); - + // Reset the form error whenever the screen gains or loses focus + // but preserve violation-related errors since those represent real validation issues + // that can only be fixed by changing the underlying data + if (!formError.startsWith(CONST.VIOLATIONS_PREFIX)) { + setFormError(''); + return; + } + // Clear missingAttendees violation if user fixed it by changing category or attendees + const isMissingAttendeesViolation = getIsMissingAttendeesViolation(policyCategories, iouCategory, iouAttendees, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled); + if (formError === 'violations.missingAttendees' && !isMissingAttendeesViolation) { + setFormError(''); + } // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes - }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); + }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, iouCategory, iouAttendees, policyCategories, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled]); useEffect(() => { // We want this effect to run only when the transaction is moving from Self DM to a expense chat @@ -928,6 +938,15 @@ function MoneyRequestConfirmationList({ return; } + // Since invoices are not expense reports that need attendee tracking, this validation should not apply to invoices + const isMissingAttendeesViolation = + iouType !== CONST.IOU.TYPE.INVOICE && + getIsMissingAttendeesViolation(policyCategories, iouCategory, iouAttendees, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled); + if (isMissingAttendeesViolation) { + setFormError('violations.missingAttendees'); + return; + } + if (isPerDiemRequest && (transaction.comment?.customUnit?.subRates ?? []).length === 0) { setFormError('iou.error.invalidSubrateLength'); return; @@ -1001,6 +1020,8 @@ function MoneyRequestConfirmationList({ showDelegateNoAccessModal, iouCategory, policyCategories, + iouAttendees, + currentUserPersonalDetails, ], ); @@ -1024,6 +1045,10 @@ function MoneyRequestConfirmationList({ if (isTypeSplit && !shouldShowReadOnlySplits) { return debouncedFormError && translate(debouncedFormError); } + // Don't show error at the bottom of the form for missing attendees + if (formError === 'violations.missingAttendees') { + return; + } return formError && translate(formError); }, [routeError, isTypeSplit, shouldShowReadOnlySplits, debouncedFormError, formError, translate]); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 8a7dc3193f5ab..aa53c6de3291d 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -384,6 +384,7 @@ function MoneyRequestConfirmationListFooter({ const shouldDisplayDistanceRateError = formError === 'iou.error.invalidRate'; const shouldDisplayTagError = formError === 'violations.tagOutOfPolicy'; const shouldDisplayCategoryError = formError === 'violations.categoryOutOfPolicy'; + const shouldDisplayAttendeesError = formError === 'violations.missingAttendees'; const showReceiptEmptyState = shouldShowReceiptEmptyState(iouType, action, policy, isPerDiemRequest); // The per diem custom unit @@ -736,7 +737,7 @@ function MoneyRequestConfirmationListFooter({ item: ( item?.displayName ?? item?.login).join(', ')} description={`${translate('iou.attendees')} ${ iouAttendees?.length && iouAttendees.length > 1 && formattedAmountPerAttendee ? `\u00B7 ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : '' @@ -750,8 +751,10 @@ function MoneyRequestConfirmationListFooter({ Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} - interactive + interactive={!isReadOnly} shouldRenderAsHTML + brickRoadIndicator={shouldDisplayAttendeesError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={shouldDisplayAttendeesError ? translate(formError) : ''} /> ), shouldShow: shouldShowAttendees, diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index ca819da12154b..894eb7fd0e1e0 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -32,6 +32,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolations from '@hooks/useTransactionViolations'; import type {ViolationField} from '@hooks/useViolations'; import useViolations from '@hooks/useViolations'; +import {initSplitExpense, updateMoneyRequestBillable, updateMoneyRequestReimbursable} from '@libs/actions/IOU/index'; +import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {filterPersonalCards, getCompanyCardDescription, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; @@ -51,13 +53,12 @@ import { isTaxTrackingEnabled, } from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {computeReportName} from '@libs/ReportNameUtils'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; import { canEditFieldOfMoneyRequest, canEditMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - // eslint-disable-next-line @typescript-eslint/no-deprecated - getReportName, getTransactionDetails, getTripIDFromTransactionParentReportID, isExpenseReport, @@ -99,7 +100,6 @@ import { import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; -import {initSplitExpense, updateMoneyRequestBillable, updateMoneyRequestReimbursable} from '@userActions/IOU'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -169,6 +169,7 @@ function MoneyRequestView({ const {getReportRHPActiveRoute} = useActiveRoute(); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); const searchContext = useSearchContext(); @@ -489,6 +490,13 @@ function MoneyRequestView({ } const hasErrors = hasMissingSmartscanFields(transaction); + const isMissingAttendeesViolation = getIsMissingAttendeesViolation( + policyCategories, + updatedTransaction?.category ?? categoryForDisplay, + actualAttendees, + currentUserPersonalDetails, + policy?.isAttendeeTrackingEnabled, + ); const getErrorForField = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { // Checks applied when creating a new expense @@ -529,6 +537,10 @@ function MoneyRequestView({ return `${violations.map((violation) => ViolationsUtils.getViolationTranslation(violation, translate, canEdit, undefined, companyCardPageURL)).join('. ')}.`; } + if (field === 'attendees' && isMissingAttendeesViolation) { + return translate('violations.missingAttendees'); + } + return ''; }; @@ -744,8 +756,9 @@ function MoneyRequestView({ ); }); - // eslint-disable-next-line @typescript-eslint/no-deprecated - const reportNameToDisplay = isFromMergeTransaction ? (updatedTransaction?.reportName ?? translate('common.none')) : getReportName(parentReport) || parentReport?.reportName; + const reportNameToDisplay = isFromMergeTransaction + ? (updatedTransaction?.reportName ?? translate('common.none')) + : (parentReport?.reportName ?? computeReportName(parentReport, allReports, allPolicies, allTransactions)); const shouldShowReport = !!parentReportID || (isFromMergeTransaction && !!reportNameToDisplay); const reportCopyValue = !canEditReport && reportNameToDisplay !== translate('common.none') ? reportNameToDisplay : undefined; const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(updatedTransaction ?? transaction); @@ -1036,6 +1049,8 @@ function MoneyRequestView({ onPress={() => { Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)); }} + brickRoadIndicator={getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('attendees')} interactive={canEdit} shouldShowRightIcon={canEdit} shouldRenderAsHTML diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 913fd2d1b6687..778bd077b6759 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -245,6 +245,7 @@ function Search({ const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {canBeMissing: true}); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {canBeMissing: true}); const [violations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const {accountID, email, login} = useCurrentUserPersonalDetails(); const [isActionLoadingSet = new Set()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}`, {canBeMissing: true, selector: isActionLoadingSetSelector}); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true, selector: columnsSelector}); @@ -384,6 +385,7 @@ function Search({ const [filteredData1, allLength] = getSections({ type, data: searchResults.data, + policies, currentAccountID: accountID, currentUserEmail: email ?? '', translate, @@ -414,6 +416,7 @@ function Search({ email, isActionLoadingSet, cardFeeds, + policies, bankAccountList, violations, ]); diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 761b2fd5747af..7ed0619ac1502 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -21,6 +21,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import type {TransactionPreviewData} from '@libs/actions/Search'; import {handleActionButtonPress as handleActionButtonPressUtil} from '@libs/actions/Search'; +import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isViolationDismissed, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -29,7 +30,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {isActionLoadingSelector} from '@src/selectors/ReportMetaData'; import type {Policy, Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {TransactionViolation} from '@src/types/onyx/TransactionViolation'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import UserInfoAndActionButtonRow from './UserInfoAndActionButtonRow'; function TransactionListItem({ @@ -63,14 +63,27 @@ function TransactionListItem({ const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); + // Use active policy (user's current workspace) as fallback for self DM tracking expenses + // This matches MoneyRequestView's approach via usePolicyForMovingExpenses() + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + // Use report's policyID as fallback when transaction doesn't have policyID directly + // Use active policy as final fallback for SelfDM (tracking expenses) + // NOTE: Using || instead of ?? to treat empty string "" as falsy + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const policyID = transactionItem.policyID || snapshotReport?.policyID || activePolicyID; + const [parentPolicy] = originalUseOnyx(ONYXKEYS.COLLECTION.POLICY, { + canBeMissing: true, + selector: (policy) => policy?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`], + }); const snapshotPolicy = useMemo(() => { - return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] ?? {}) as Policy; - }, [snapshot, transactionItem.policyID]); + return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}) as Policy; + }, [snapshot, policyID]); + // Fetch policy categories directly from Onyx since they are not included in the search snapshot + const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`, {canBeMissing: true}); const [lastPaymentMethod] = useOnyx(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {canBeMissing: true}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const [parentReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionItem.reportID)}`, {canBeMissing: true}); - const [parentPolicy] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(transactionItem.policyID)}`, {canBeMissing: true}); const [transactionThreadReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionItem?.reportAction?.childReportID}`, {canBeMissing: true}); const [transaction] = originalUseOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionItem.transactionID)}`, {canBeMissing: true}); const parentReportActionSelector = useCallback( @@ -122,20 +135,34 @@ function TransactionListItem({ transactionItem.shouldShowYearExported, ]); - // Use parentReport/parentPolicy as fallbacks when snapshotReport/snapshotPolicy are empty - // to fix offline issues where newly created reports aren't in the search snapshot yet - const reportForViolations = isEmptyObject(snapshotReport) ? parentReport : snapshotReport; - const policyForViolations = isEmptyObject(snapshotPolicy) ? parentPolicy : snapshotPolicy; + // Prefer live Onyx policy data over snapshot to ensure fresh policy settings + // like isAttendeeTrackingEnabled is not missing + // Use snapshotReport/snapshotPolicy as fallbacks to fix offline issues where + // newly created reports aren't in the search snapshot yet + const policyForViolations = parentPolicy ?? snapshotPolicy; + const reportForViolations = parentReport ?? snapshotReport; const transactionViolations = useMemo(() => { - return mergeProhibitedViolations( - (violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionItem.transactionID}`] ?? []).filter( - (violation: TransactionViolation) => - !isViolationDismissed(transactionItem, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, policyForViolations) && - shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transactionItem), - ), + const onyxViolations = (violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionItem.transactionID}`] ?? []).filter( + (violation: TransactionViolation) => + !isViolationDismissed(transactionItem, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, policyForViolations) && + shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transactionItem), + ); + + // Sync missingAttendees violation with current policy category settings (can be removed later when BE handles this) + // Use live transaction data (attendees, category) to ensure we check against current state, not stale snapshot + const attendeeOnyxViolations = syncMissingAttendeesViolation( + onyxViolations, + policyCategories, + transaction?.category ?? transactionItem.category ?? '', + transaction?.comment?.attendees ?? transactionItem.attendees, + currentUserDetails, + policyForViolations?.isAttendeeTrackingEnabled ?? false, + policyForViolations?.type === CONST.POLICY.TYPE.CORPORATE, ); - }, [policyForViolations, reportForViolations, transactionItem, violations, currentUserDetails.email, currentUserDetails.accountID]); + + return mergeProhibitedViolations(attendeeOnyxViolations); + }, [policyForViolations, reportForViolations, policyCategories, transactionItem, currentUserDetails, violations]); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index f86a875b35edd..acd96ca0987a8 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -6,7 +6,7 @@ import type {TransactionViolation, ViolationName} from '@src/types/onyx'; /** * Names of Fields where violations can occur. */ -const validationFields = ['amount', 'billable', 'category', 'comment', 'date', 'merchant', 'receipt', 'tag', 'tax', 'customUnitRateID', 'none'] as const; +const validationFields = ['amount', 'billable', 'category', 'comment', 'date', 'merchant', 'receipt', 'tag', 'tax', 'attendees', 'customUnitRateID', 'none'] as const; type ViolationField = TupleToUnion; @@ -28,6 +28,7 @@ const violationNameToField: Record 'date', missingCategory: () => 'category', missingComment: () => 'comment', + missingAttendees: () => 'attendees', missingTag: () => 'tag', modifiedAmount: () => 'amount', modifiedDate: () => 'date', diff --git a/src/languages/de.ts b/src/languages/de.ts index bb2034ab21771..87556f4086e6d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6269,6 +6269,10 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard title: 'Kategorierichtlinien', approver: 'Genehmiger', requireDescription: 'Beschreibung erforderlich', + requireFields: 'Felder verpflichtend machen', + requiredFieldsTitle: 'Pflichtfelder', + requiredFieldsDescription: (categoryName: string) => `Dies gilt für alle Ausgaben, die als ${categoryName} kategorisiert sind.`, + requireAttendees: 'Teilnehmer erforderlich machen', descriptionHint: 'Hinweis zur Beschreibung', descriptionHintDescription: (categoryName: string) => `Mitarbeitende daran erinnern, zusätzliche Informationen für Ausgaben der Kategorie „${categoryName}“ anzugeben. Dieser Hinweis erscheint im Beschreibungsfeld von Ausgaben.`, @@ -7256,6 +7260,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Datum älter als ${maxAge} Tage`, missingCategory: 'Fehlende Kategorie', missingComment: 'Beschreibung für ausgewählte Kategorie erforderlich', + missingAttendees: 'Für diese Kategorie sind mehrere Teilnehmer erforderlich', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Fehlende ${tagName ?? 'Tag'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/en.ts b/src/languages/en.ts index d746bc0a17063..dd8121d49c72e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6150,6 +6150,10 @@ const translations = { title: 'Category rules', approver: 'Approver', requireDescription: 'Require description', + requireFields: 'Require fields', + requiredFieldsTitle: 'Required fields', + requiredFieldsDescription: (categoryName: string) => `This will apply to all expenses categorized as ${categoryName}.`, + requireAttendees: 'Require attendees', descriptionHint: 'Description hint', descriptionHintDescription: (categoryName: string) => `Remind employees to provide additional information for “${categoryName}” spend. This hint appears in the description field on expenses.`, @@ -7187,6 +7191,7 @@ const translations = { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, missingCategory: 'Missing category', missingComment: 'Description required for selected category', + missingAttendees: 'Multiple attendees required for this category', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Missing ${tagName ?? 'tag'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/es.ts b/src/languages/es.ts index 9ad4636c6c821..f8824f8208067 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5912,6 +5912,10 @@ ${amount} para ${merchant} - ${date}`, title: 'Reglas de categoría', approver: 'Aprobador', requireDescription: 'Requerir descripción', + requireFields: 'Requerir campos', + requiredFieldsTitle: 'Campos obligatorios', + requiredFieldsDescription: (categoryName) => `Esto se aplicará a todos los gastos categorizados como ${categoryName}.`, + requireAttendees: 'Requerir asistentes', descriptionHint: 'Sugerencia de descripción', descriptionHintDescription: (categoryName) => `Recuerda a los empleados que deben proporcionar información adicional para los gastos de “${categoryName}”. Esta sugerencia aparece en el campo de descripción en los gastos.`, @@ -7335,6 +7339,7 @@ ${amount} para ${merchant} - ${date}`, maxAge: ({maxAge}) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', missingComment: 'Descripción obligatoria para la categoría seleccionada', + missingAttendees: 'Se requieren múltiples asistentes para esta categoría', missingTag: ({tagName} = {}) => `Falta ${tagName ?? 'etiqueta'}`, modifiedAmount: ({type, displayPercentVariance}) => { switch (type) { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6ee6ece3ac92f..275d6fc3db874 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6278,6 +6278,10 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin title: 'Règles de catégorie', approver: 'Approbateur', requireDescription: 'Description requise', + requireFields: 'Rendre les champs obligatoires', + requiredFieldsTitle: 'Champs obligatoires', + requiredFieldsDescription: (categoryName: string) => `Cela s’appliquera à toutes les dépenses classées dans la catégorie ${categoryName}.`, + requireAttendees: 'Exiger des participants', descriptionHint: 'Indice de description', descriptionHintDescription: (categoryName: string) => `Rappelez aux employés de fournir des informations supplémentaires pour les dépenses « ${categoryName} ». Cet indice apparaît dans le champ de description des dépenses.`, @@ -7267,6 +7271,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date de plus de ${maxAge} jours`, missingCategory: 'Catégorie manquante', missingComment: 'Description requise pour la catégorie sélectionnée', + missingAttendees: 'Plusieurs participants sont requis pour cette catégorie', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Manquant ${tagName ?? 'étiquette'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/it.ts b/src/languages/it.ts index 9e366e325d490..1b2ffb2284de5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6251,6 +6251,10 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori title: 'Regole di categoria', approver: 'Approvatore', requireDescription: 'Richiedi descrizione', + requireFields: 'Rendi obbligatori i campi', + requiredFieldsTitle: 'Campi obbligatori', + requiredFieldsDescription: (categoryName: string) => `Questo si applicherà a tutte le spese classificate come ${categoryName}.`, + requireAttendees: 'Richiedi partecipanti', descriptionHint: 'Suggerimento per la descrizione', descriptionHintDescription: (categoryName: string) => `Ricorda ai dipendenti di fornire informazioni aggiuntive per la spesa in “${categoryName}”. Questo suggerimento appare nel campo descrizione sulle spese.`, @@ -7242,6 +7246,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Data precedente a ${maxAge} giorni`, missingCategory: 'Categoria mancante', missingComment: 'Descrizione richiesta per la categoria selezionata', + missingAttendees: 'Più partecipanti obbligatori per questa categoria', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Manca ${tagName ?? 'etichetta'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7af8dbb365b40..bdf2886ed6b60 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6210,6 +6210,10 @@ ${reportName} title: 'カテゴリルール', approver: '承認者', requireDescription: '説明を必須にする', + requireFields: 'フィールドを必須にする', + requiredFieldsTitle: '必須項目', + requiredFieldsDescription: (categoryName: string) => `これは${categoryName}として分類されたすべての経費に適用されます。`, + requireAttendees: '参加者の入力を必須にする', descriptionHint: '説明のヒント', descriptionHintDescription: (categoryName: string) => `従業員に「${categoryName}」での支出について追加情報を提供するよう促します。このヒントは経費の説明欄に表示されます。`, descriptionHintLabel: 'ヒント', @@ -7186,6 +7190,7 @@ ${reportName} maxAge: ({maxAge}: ViolationsMaxAgeParams) => `${maxAge}日より前の日付`, missingCategory: 'カテゴリ未設定', missingComment: '選択したカテゴリーには説明が必要です', + missingAttendees: 'このカテゴリには複数の参加者が必要です', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `${tagName ?? 'タグ'} が見つかりません`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 197f4d7db82df..a44ef09572c5b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6242,6 +6242,10 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten title: 'Categorisatieregels', approver: 'Fiatteur', requireDescription: 'Beschrijving vereist', + requireFields: 'Velden verplicht stellen', + requiredFieldsTitle: 'Verplichte velden', + requiredFieldsDescription: (categoryName: string) => `Dit is van toepassing op alle uitgaven die zijn gecategoriseerd als ${categoryName}.`, + requireAttendees: 'Aanwezigen verplicht stellen', descriptionHint: 'Beschrijvingstip', descriptionHintDescription: (categoryName: string) => `Herinner medewerkers eraan om extra informatie te geven voor uitgaven in de categorie “${categoryName}”. Deze tip verschijnt in het omschrijvingsveld van uitgaven.`, @@ -7230,6 +7234,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Datum ouder dan ${maxAge} dagen`, missingCategory: 'Ontbrekende categorie', missingComment: 'Beschrijving vereist voor geselecteerde categorie', + missingAttendees: 'Meerdere deelnemers vereist voor deze categorie', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Ontbreekt ${tagName ?? 'label'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 57bcced767c70..40c80bcab7d87 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6230,6 +6230,10 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i title: 'Zasady kategorii', approver: 'Akceptujący', requireDescription: 'Wymagaj opisu', + requireFields: 'Wymagaj pól', + requiredFieldsTitle: 'Wymagane pola', + requiredFieldsDescription: (categoryName: string) => `To będzie miało zastosowanie do wszystkich wydatków skategoryzowanych jako ${categoryName}.`, + requireAttendees: 'Wymagaj uczestników', descriptionHint: 'Podpowiedź opisu', descriptionHintDescription: (categoryName: string) => `Przypominaj pracownikom o podaniu dodatkowych informacji dotyczących wydatków w kategorii „${categoryName}”. Ta podpowiedź pojawia się w polu opisu przy wydatkach.`, @@ -7215,6 +7219,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Data starsza niż ${maxAge} dni`, missingCategory: 'Brak kategorii', missingComment: 'Opis jest wymagany dla wybranej kategorii', + missingAttendees: 'Wymaganych jest wielu uczestników dla tej kategorii', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Brakujące ${tagName ?? 'tag'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 428f7c4905b05..ced28ef6709e2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6232,6 +6232,10 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe title: 'Regras de categoria', approver: 'Aprovador', requireDescription: 'Exigir descrição', + requireFields: 'Exigir campos', + requiredFieldsTitle: 'Campos obrigatórios', + requiredFieldsDescription: (categoryName: string) => `Isso será aplicado a todas as despesas categorizadas como ${categoryName}.`, + requireAttendees: 'Exigir participantes', descriptionHint: 'Dica de descrição', descriptionHintDescription: (categoryName: string) => `Lembre os funcionários de fornecer informações adicionais para gastos em “${categoryName}”. Essa dica aparece no campo de descrição das despesas.`, @@ -7219,6 +7223,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Data anterior a ${maxAge} dias`, missingCategory: 'Categoria ausente', missingComment: 'Descrição obrigatória para a categoria selecionada', + missingAttendees: 'Vários participantes são obrigatórios para esta categoria', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Faltando ${tagName ?? 'Tag'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 6a8e12202e004..564caf53a3ad5 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6109,6 +6109,10 @@ ${reportName} title: '类别规则', approver: '审批人', requireDescription: '要求描述', + requireFields: '必填字段', + requiredFieldsTitle: '必填项', + requiredFieldsDescription: (categoryName: string) => `这将适用于所有被归类为 ${categoryName} 的费用。`, + requireAttendees: '要求与会者', descriptionHint: '描述提示', descriptionHintDescription: (categoryName: string) => `提醒员工为“${categoryName}”支出提供更多信息。此提示将显示在报销单的描述字段中。`, descriptionHintLabel: '提示', @@ -7067,6 +7071,7 @@ ${reportName} maxAge: ({maxAge}: ViolationsMaxAgeParams) => `日期早于 ${maxAge} 天`, missingCategory: '缺少类别', missingComment: '所选类别需要填写描述', + missingAttendees: '此类别需要多个参与者', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `缺少 ${tagName ?? '标签'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/libs/API/parameters/SetPolicyCategoryAttendeesRequiredParams.ts b/src/libs/API/parameters/SetPolicyCategoryAttendeesRequiredParams.ts new file mode 100644 index 0000000000000..abaa94f241cc5 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryAttendeesRequiredParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryAttendeesRequiredParams = { + policyID: string; + categoryName: string; + areAttendeesRequired: boolean; +}; + +export default SetPolicyCategoryAttendeesRequiredParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 33efdf52df1f0..c547705883246 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -347,6 +347,7 @@ export type {default as AddDelegateParams} from './AddDelegateParams'; export type {default as UpdateDelegateRoleParams} from './UpdateDelegateRoleParams'; export type {default as OpenCardDetailsPageParams} from './OpenCardDetailsPageParams'; export type {default as SetPolicyCategoryDescriptionRequiredParams} from './SetPolicyCategoryDescriptionRequiredParams'; +export type {default as SetPolicyCategoryAttendeesRequiredParams} from './SetPolicyCategoryAttendeesRequiredParams'; export type {default as SetPolicyCategoryApproverParams} from './SetPolicyCategoryApproverParams'; export type {default as SetWorkspaceCategoryDescriptionHintParams} from './SetWorkspaceCategoryDescriptionHintParams'; export type {default as SetPolicyCategoryTaxParams} from './SetPolicyCategoryTaxParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index b808473b64bc6..e86d5599ad9ee 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -265,6 +265,7 @@ const WRITE_COMMANDS = { SET_POLICY_ATTENDEE_TRACKING_ENABLED: 'SetPolicyAttendeeTrackingEnabled', SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED: 'SetPolicyRequireCompanyCardsEnabled', SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED: 'SetPolicyCategoryDescriptionRequired', + SET_POLICY_CATEGORY_ATTENDEES_REQUIRED: 'SetPolicyCategoryAttendeesRequired', SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT: 'SetWorkspaceCategoryDescriptionHint', SET_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'SetPolicyCategoryReceiptsRequired', REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'RemoveWorkspaceCategoryReceiptsRequired', @@ -793,6 +794,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED]: Parameters.SetPolicyCategoryAttendeesRequiredParams; [WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT]: Parameters.SetWorkspaceCategoryDescriptionHintParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.SetPolicyCategoryReceiptsRequiredParams; [WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.RemovePolicyCategoryReceiptsRequiredParams; diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts new file mode 100644 index 0000000000000..0da831478833e --- /dev/null +++ b/src/libs/AttendeeUtils.ts @@ -0,0 +1,97 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import type {PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; + +/** Formats the title for requiredFields menu item based on which fields are enabled in the policy category */ +function formatRequiredFieldsTitle(translate: LocaleContextProps['translate'], policyCategory: PolicyCategory, isAttendeeTrackingEnabled = false): string { + const enabledFields: string[] = []; + + // Attendees field should show first when both are selected and attendee tracking is enabled + if (isAttendeeTrackingEnabled && policyCategory.areAttendeesRequired) { + enabledFields.push(translate('iou.attendees')); + } + + if (policyCategory.areCommentsRequired) { + enabledFields.push(translate('common.description')); + } + + if (enabledFields.length === 0) { + return ''; + } + + const [first, ...rest] = enabledFields; + const capitalizedFirst = first.charAt(0).toUpperCase() + first.slice(1); + const lowercasedRest = rest.map((field) => field.charAt(0).toLowerCase() + field.slice(1)); + return [capitalizedFirst, ...lowercasedRest].join(', '); +} + +/** Returns whether there are missing attendees for the given category */ +function getIsMissingAttendeesViolation( + policyCategories: PolicyCategories | undefined, + category: string, + iouAttendees: Attendee[] | string | undefined, + userPersonalDetails: CurrentUserPersonalDetails, + isAttendeeTrackingEnabled = false, +) { + const areAttendeesRequired = !!policyCategories?.[category ?? '']?.areAttendeesRequired; + // If attendee tracking is disabled at the policy level, don't enforce attendee requirement + if (!isAttendeeTrackingEnabled || !areAttendeesRequired) { + return false; + } + + const creatorLogin = userPersonalDetails.login ?? ''; + const creatorEmail = userPersonalDetails.email ?? ''; + const attendees = Array.isArray(iouAttendees) ? iouAttendees : []; + // Check both login and email since attendee objects may have identifier in either property + const attendeesMinusCreatorCount = attendees.filter((a) => { + const attendeeIdentifier = a?.login ?? a?.email; + return attendeeIdentifier !== creatorLogin && attendeeIdentifier !== creatorEmail; + }).length; + + if (attendees.length === 0 || attendeesMinusCreatorCount === 0) { + return true; + } + + return false; +} + +/** + * Syncs the missingAttendees violation with current policy settings. + * - Adds the violation when it should show but isn't present from BE + * - Removes stale BE violation when policy settings changed (e.g., category no longer requires attendees) + */ +function syncMissingAttendeesViolation( + violations: T[], + policyCategories: PolicyCategories | undefined, + category: string, + attendees: Attendee[] | undefined, + userPersonalDetails: CurrentUserPersonalDetails, + isAttendeeTrackingEnabled: boolean, + isControlPolicy: boolean, +): T[] { + const hasMissingAttendeesViolation = violations.some((v) => v.name === CONST.VIOLATIONS.MISSING_ATTENDEES); + const shouldShowMissingAttendees = + isControlPolicy && getIsMissingAttendeesViolation(policyCategories ?? {}, category ?? '', attendees ?? [], userPersonalDetails, isAttendeeTrackingEnabled); + + if (!hasMissingAttendeesViolation && shouldShowMissingAttendees) { + // Add violation when it should show but isn't present from BE + return [ + ...violations, + { + name: CONST.VIOLATIONS.MISSING_ATTENDEES, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + } as unknown as T, + ]; + } + if (hasMissingAttendeesViolation && !shouldShowMissingAttendees) { + // Remove stale BE violation when policy settings changed + return violations.filter((v) => v.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); + } + + return violations; +} + +export {formatRequiredFieldsTitle, getIsMissingAttendeesViolation, syncMissingAttendeesViolation}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8e70d36864994..909aa3f5a5855 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -434,6 +434,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceOverviewSharePage').default, [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../../pages/workspace/WorkspaceOverviewCurrencyPage').default, [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: () => require('../../../../pages/workspace/categories/CategorySettingsPage').default, + [SCREENS.WORKSPACE.CATEGORY_REQUIRED_FIELDS]: () => require('../../../../pages/workspace/categories/CategoryRequiredFieldsPage').default, [SCREENS.WORKSPACE.ADDRESS]: () => require('../../../../pages/workspace/WorkspaceOverviewAddressPage').default, [SCREENS.WORKSPACE.PLAN]: () => require('../../../../pages/workspace/WorkspaceOverviewPlanTypePage').default, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 6eae43d74ff2d..0beb9e8ba14f1 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -213,6 +213,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: { path: ROUTES.WORKSPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.route, }, + [SCREENS.WORKSPACE.CATEGORY_REQUIRED_FIELDS]: { + path: ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.route, + }, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { path: ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index a9be73fde154d..524f5f86b7cf7 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -339,6 +339,10 @@ type SettingsNavigatorParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; + [SCREENS.WORKSPACE.CATEGORY_REQUIRED_FIELDS]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.UPGRADE]: { policyID?: string; featureName?: string; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 18195b051b40c..37673b9ee0c21 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11188,7 +11188,12 @@ function createDraftTransactionAndNavigateToParticipantSelector( .find((action) => isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID === transactionID); const {created, amount, currency, merchant, mccGroup} = getTransactionDetails(transaction) ?? {}; - const comment = getTransactionCommentObject(transaction); + const baseComment = getTransactionCommentObject(transaction); + // Use modifiedAttendees if present (for edited transactions), otherwise use the attendees from comment + const comment = { + ...baseComment, + attendees: transaction?.modifiedAttendees ?? baseComment.attendees, + }; removeDraftTransactions(); @@ -11206,6 +11211,7 @@ function createDraftTransactionAndNavigateToParticipantSelector( comment, merchant, modifiedMerchant: '', + modifiedAttendees: undefined, mccGroup, } as Transaction); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index f4968b00c2d81..772cbaea3ce7c 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -147,6 +147,7 @@ type TransactionWithdrawalIDGroupSorting = ColumnSortMapping; currentSearch: SearchKey; currentAccountID: number | undefined; currentUserEmail: string; @@ -364,6 +365,7 @@ type ArchivedReportsIDSet = ReadonlySet; type GetSectionsParams = { type: SearchDataTypes; data: OnyxTypes.SearchResults['data']; + policies?: OnyxCollection; currentAccountID: number | undefined; currentUserEmail: string; translate: LocalizedTranslate; @@ -1718,6 +1720,7 @@ function getReportActionsSections(data: OnyxTypes.SearchResults['data']): [Repor */ function getReportSections({ data, + policies, currentSearch, currentAccountID, currentUserEmail, @@ -1816,7 +1819,8 @@ function getReportSections({ const formattedStatus = getReportStatusTranslation({stateNum: reportItem.stateNum, statusNum: reportItem.statusNum, translate}); const allReportTransactions = getTransactionsForReport(data, reportItem.reportID); - const policy = getPolicyFromKey(data, reportItem); + const policyFromKey = getPolicyFromKey(data, reportItem); + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem?.policyID ?? String(CONST.DEFAULT_NUMBER_ID)}`] ?? policyFromKey; const hasAnyViolationsForReport = hasAnyViolations( reportItem.reportID, @@ -2096,6 +2100,7 @@ function getListItem(type: SearchDataTypes, status: SearchStatus, groupBy?: Sear function getSections({ type, data, + policies, currentAccountID, currentUserEmail, translate, @@ -2121,6 +2126,7 @@ function getSections({ if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { return getReportSections({ data, + policies, currentSearch, currentAccountID, currentUserEmail, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 4a2c9d264ccd2..b9f9c1d9dfbcd 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -533,7 +533,7 @@ function shouldShowAttendees(iouType: IOUType, policy: OnyxEntry): boole // For backwards compatibility with Expensify Classic, we assume that Attendee Tracking is enabled by default on // Control policies if the policy does not contain the attribute - return policy?.isAttendeeTrackingEnabled ?? true; + return policy?.isAttendeeTrackingEnabled ?? false; } /** @@ -1538,6 +1538,7 @@ function shouldShowViolation( const isSubmitter = isCurrentUserSubmitter(iouReport); const isPolicyMember = isPolicyMemberPolicyUtils(policy, currentUserEmail); const isReportOpen = isOpenExpenseReport(iouReport); + const isAttendeeTrackingEnabled = policy?.isAttendeeTrackingEnabled ?? false; if (violationName === CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE) { return isSubmitter || isPolicyAdmin(policy); @@ -1555,6 +1556,10 @@ function shouldShowViolation( return isPolicyMember && !isSubmitter && !isReportOpen; } + if (violationName === CONST.VIOLATIONS.MISSING_ATTENDEES) { + return isAttendeeTrackingEnabled; + } + if (violationName === CONST.VIOLATIONS.MISSING_CATEGORY && isCategoryBeingAnalyzed(transaction)) { return false; } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index c6add2c2bb4c3..62fea21aa442e 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -8,6 +8,7 @@ import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {isReceiptError} from '@libs/ErrorUtils'; +import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; import Parser from '@libs/Parser'; import {getDistanceRateCustomUnitRate, getPerDiemRateCustomUnitRate, getSortedTagKeys, isDefaultTagName, isTaxTrackingEnabled} from '@libs/PolicyUtils'; import {isCurrentUserSubmitter} from '@libs/ReportUtils'; @@ -336,6 +337,7 @@ const ViolationsUtils = { // const hasOverTripLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_TRIP_LIMIT); const hasCategoryOverLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_CATEGORY_LIMIT); const hasMissingCommentViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_COMMENT); + const hasMissingAttendeesViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_ATTENDEES); const hasTaxOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAX_OUT_OF_POLICY); const isDistanceRequest = TransactionUtils.isDistanceRequest(updatedTransaction); const isPerDiemRequest = TransactionUtils.isPerDiemRequest(updatedTransaction); @@ -384,6 +386,40 @@ const ViolationsUtils = { const shouldCategoryShowOverLimitViolation = canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && expenseAmount > categoryOverLimit && isControlPolicy; const shouldShowMissingComment = !isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && !updatedTransaction.comment?.comment && isControlPolicy; + const attendees = updatedTransaction.modifiedAttendees ?? updatedTransaction.comment?.attendees ?? []; + const isAttendeeTrackingEnabled = policy.isAttendeeTrackingEnabled ?? false; + // Filter out the owner/creator when checking attendance count - expense is valid if at least one non-owner attendee is present + const ownerAccountID = iouReport?.ownerAccountID; + // Calculate attendees minus owner. When ownerAccountID is known, filter by accountID. + // When ownerAccountID is undefined (offline split where iouReport is unavailable), + // fallback to using login/email to identify the owner (similar to AttendeeUtils approach). + let attendeesMinusOwnerCount: number; + if (ownerAccountID !== undefined) { + // Normal case: filter by accountID + attendeesMinusOwnerCount = attendees.filter((a) => a?.accountID !== ownerAccountID).length; + } else { + // Offline scenario: ownerAccountID unavailable, use login/email as fallback + const currentUserEmail = getCurrentUserEmail(); + if (currentUserEmail) { + // Filter by login or email to identify owner + attendeesMinusOwnerCount = attendees.filter((a) => { + const attendeeIdentifier = a?.login ?? a?.email; + return attendeeIdentifier !== currentUserEmail; + }).length; + } else { + // Can't identify owner at all - if there are attendees, assume owner is one of them + // This means we need at least 2 attendees to have a non-owner attendee + attendeesMinusOwnerCount = Math.max(0, attendees.length - 1); + } + } + + const shouldShowMissingAttendees = + !isInvoiceTransaction && + isAttendeeTrackingEnabled && + !!policyCategories?.[categoryName ?? '']?.areAttendeesRequired && + isControlPolicy && + (attendees.length === 0 || attendeesMinusOwnerCount === 0); + const hasFutureDateViolation = transactionViolations.some((violation) => violation.name === 'futureDate'); // Add 'futureDate' violation if transaction date is in the future and policy type is corporate if (!hasFutureDateViolation && shouldDisplayFutureDateViolation) { @@ -466,6 +502,18 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_COMMENT}); } + if (!hasMissingAttendeesViolation && shouldShowMissingAttendees) { + newTransactionViolations.push({ + name: CONST.VIOLATIONS.MISSING_ATTENDEES, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }); + } + + if (hasMissingAttendeesViolation && !shouldShowMissingAttendees) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_ATTENDEES}); + } + if (isPolicyTrackTaxEnabled && !hasTaxOutOfPolicyViolation && !isTaxInPolicy) { newTransactionViolations.push({name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); } @@ -535,6 +583,8 @@ const ViolationsUtils = { return translate('violations.missingCategory'); case 'missingComment': return translate('violations.missingComment'); + case 'missingAttendees': + return translate('violations.missingAttendees'); case 'missingTag': return translate('violations.missingTag', {tagName}); case 'modifiedAmount': diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index f37a09c5586fd..2832701035b3f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -4439,6 +4439,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U const hasModifiedTaxCode = 'taxCode' in transactionChanges; const hasModifiedDate = 'date' in transactionChanges; const hasModifiedMerchant = 'merchant' in transactionChanges; + const hasModifiedAttendees = 'attendees' in transactionChanges; const isInvoice = isInvoiceReportReportUtils(iouReport); if ( @@ -4457,7 +4458,8 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U hasModifiedAmount || hasModifiedCreated || hasModifiedReimbursable || - hasModifiedTaxCode) + hasModifiedTaxCode || + hasModifiedAttendees) ) { const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; // If the amount, currency or date have been modified, we remove the duplicate violations since they would be out of date as the transaction has changed @@ -5486,6 +5488,7 @@ type AddTrackedExpenseToPolicyParam = { moneyRequestCreatedReportActionID: string | undefined; moneyRequestPreviewReportActionID: string; distance: number | undefined; + attendees: string | undefined; } & ConvertTrackedWorkspaceParams; type ConvertTrackedExpenseToRequestParams = { @@ -5574,6 +5577,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac comment, created, merchant, + attendees: attendees ? JSON.stringify(attendees) : undefined, reimbursable: true, transactionID, actionableWhisperReportActionID, diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 4b534215cd6d3..0b2f8b64dccff 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -11,6 +11,7 @@ import type { OpenPolicyCategoriesPageParams, RemovePolicyCategoryReceiptsRequiredParams, SetPolicyCategoryApproverParams, + SetPolicyCategoryAttendeesRequiredParams, SetPolicyCategoryDescriptionRequiredParams, SetPolicyCategoryMaxAmountParams, SetPolicyCategoryReceiptsRequiredParams, @@ -1522,6 +1523,68 @@ function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, t API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); } +function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: string, areAttendeesRequired: boolean, policyCategories: PolicyCategories = {}) { + const policyCategoryToUpdate = policyCategories?.[categoryName]; + const originalAreAttendeesRequired = policyCategoryToUpdate?.areAttendeesRequired; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + areAttendeesRequired: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + areAttendeesRequired, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + areAttendeesRequired: null, + }, + areAttendeesRequired, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + areAttendeesRequired: null, + }, + areAttendeesRequired: originalAreAttendeesRequired, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryAttendeesRequiredParams = { + policyID, + categoryName, + areAttendeesRequired, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED, parameters, onyxData); +} + export { buildOptimisticPolicyCategories, buildOptimisticMccGroup, @@ -1536,6 +1599,7 @@ export { removePolicyCategoryReceiptsRequired, renamePolicyCategory, setPolicyCategoryApprover, + setPolicyCategoryAttendeesRequired, setPolicyCategoryDescriptionRequired, buildOptimisticPolicyWithExistingCategories, setPolicyCategoryGLCode, diff --git a/src/pages/iou/request/step/IOURequestStepAttendees.tsx b/src/pages/iou/request/step/IOURequestStepAttendees.tsx index 4f38d4b27cd01..e8960b6fbdda0 100644 --- a/src/pages/iou/request/step/IOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/IOURequestStepAttendees.tsx @@ -1,6 +1,5 @@ import {deepEqual} from 'fast-equals'; import React, {useCallback, useState} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -16,38 +15,27 @@ import MoneyRequestAttendeeSelector from '@pages/iou/request/MoneyRequestAttende import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import StepScreenWrapper from './StepScreenWrapper'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; -type IOURequestStepAttendeesOnyxProps = { - /** The policy of the report */ - policy: OnyxEntry; - - /** Collection of categories attached to a policy */ - policyCategories: OnyxEntry; - - /** Collection of tags attached to a policy */ - policyTags: OnyxEntry; -}; - -type IOURequestStepAttendeesProps = IOURequestStepAttendeesOnyxProps & WithWritableReportOrNotFoundProps; +type IOURequestStepAttendeesProps = WithWritableReportOrNotFoundProps; function IOURequestStepAttendees({ route: { params: {transactionID, reportID, iouType, backTo, action}, }, - report, - policy, - policyTags, - policyCategories, }: IOURequestStepAttendeesProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isEditing = action === CONST.IOU.ACTION.EDIT; // eslint-disable-next-line rulesdir/no-default-id-values const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID || CONST.DEFAULT_NUMBER_ID}`, {canBeMissing: true}); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); + const policyID = report?.policyID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); const [attendees, setAttendees] = useState(() => getOriginalAttendees(transaction, currentUserPersonalDetails)); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`, {canBeMissing: true}); const previousAttendees = usePrevious(attendees); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index fd8d5a1523d78..21bd27ff80a6d 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -61,8 +61,8 @@ import { } from '@libs/ReportUtils'; import {endSpan} from '@libs/telemetry/activeSpans'; import { + getAttendees, getDefaultTaxCode, - getOriginalAttendees, getRateID, getRequestType, getValidWaypoints, @@ -1396,7 +1396,7 @@ function IOURequestStepConfirmation({ transaction={transaction} selectedParticipants={participants} iouAmount={transaction?.amount ?? 0} - iouAttendees={getOriginalAttendees(transaction, currentUserPersonalDetails)} + iouAttendees={getAttendees(transaction, currentUserPersonalDetails)} iouComment={transaction?.comment?.comment ?? ''} iouCurrencyCode={transaction?.currency} iouIsBillable={transaction?.billable} diff --git a/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx b/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx new file mode 100644 index 0000000000000..728e9792dd387 --- /dev/null +++ b/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import {View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {setPolicyCategoryAttendeesRequired, setPolicyCategoryDescriptionRequired} from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type CategoryRequiredFieldsPageProps = PlatformStackScreenProps; + +function CategoryRequiredFieldsPage({ + route: { + params: {policyID, categoryName}, + }, +}: CategoryRequiredFieldsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); + const decodedCategoryName = getDecodedCategoryName(categoryName); + + const policyCategory = policyCategories?.[categoryName]; + const areCommentsRequired = policyCategory?.areCommentsRequired ?? false; + const areAttendeesRequired = policyCategory?.areAttendeesRequired ?? false; + const isAttendeeTrackingEnabled = policy?.isAttendeeTrackingEnabled ?? false; + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + + + + + + + + {translate('workspace.rules.categoryRules.requireDescription')} + { + setPolicyCategoryDescriptionRequired(policyID, categoryName, !areCommentsRequired, policyCategories); + }} + /> + + + + {isAttendeeTrackingEnabled && ( + + + + {translate('workspace.rules.categoryRules.requireAttendees')} + { + setPolicyCategoryAttendeesRequired(policyID, categoryName, !areAttendeesRequired, policyCategories); + }} + /> + + + + )} + + + + ); +} + +CategoryRequiredFieldsPage.displayName = 'CategoryRequiredFieldsPage'; + +export default CategoryRequiredFieldsPage; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index e22c8bc46aa1c..1cb19e136ca61 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -17,6 +17,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; +import {formatRequiredFieldsTitle} from '@libs/AttendeeUtils'; import {formatDefaultTaxRateText, formatRequireReceiptsOverText, getCategoryApproverRule, getCategoryDefaultTaxRate, getDecodedCategoryName} from '@libs/CategoryUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import {getLatestErrorMessageField} from '@libs/ErrorUtils'; @@ -28,13 +29,7 @@ import {getWorkflowApprovalsUnavailable, isControlPolicy} from '@libs/PolicyUtil import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import { - clearCategoryErrors, - deleteWorkspaceCategories, - setPolicyCategoryDescriptionRequired, - setWorkspaceCategoryDescriptionHint, - setWorkspaceCategoryEnabled, -} from '@userActions/Policy/Category'; +import {clearCategoryErrors, deleteWorkspaceCategories, setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -127,6 +122,22 @@ function CategorySettingsPage({ return formatRequireReceiptsOverText(translate, policy, policyCategory?.maxAmountNoReceipt); }, [policy, policyCategory?.maxAmountNoReceipt, translate]); + const requiredFieldsTitle = useMemo(() => { + if (!policyCategory) { + return ''; + } + return formatRequiredFieldsTitle(translate, policyCategory, policy?.isAttendeeTrackingEnabled); + }, [policyCategory, translate, policy?.isAttendeeTrackingEnabled]); + + const requireFieldsPendingAction = useMemo(() => { + if (policy?.isAttendeeTrackingEnabled) { + // Pending fields are objects so we can't use nullish coalescing + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return policyCategory?.pendingFields?.areAttendeesRequired || policyCategory?.pendingFields?.areCommentsRequired; + } + return policyCategory?.pendingFields?.areCommentsRequired; + }, [policyCategory?.pendingFields, policy?.isAttendeeTrackingEnabled]); + if (!policyCategory) { return ; } @@ -288,40 +299,38 @@ function CategorySettingsPage({ /> + {areCommentsRequired && ( + + { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_DESCRIPTION_HINT.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + + )} + + {!isThereAnyAccountingConnection && ( + { + if (shouldPreventDisableOrDelete) { + setIsCannotDeleteOrDisableLastCategoryModalVisible(true); + return; + } + setDeleteCategoryConfirmModalVisible(true); + }} + /> + )} + {!!policy?.areRulesEnabled && ( <> {translate('workspace.rules.categoryRules.title')} - - - - {translate('workspace.rules.categoryRules.requireDescription')} - { - if (policyCategory.commentHint && areCommentsRequired) { - setWorkspaceCategoryDescriptionHint(policyID, categoryName, '', policyCategories); - } - setPolicyCategoryDescriptionRequired(policyID, categoryName, !areCommentsRequired, policyCategories); - }} - /> - - - - {!!policyCategory?.areCommentsRequired && ( - - { - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_DESCRIPTION_HINT.getRoute(policyID, policyCategory.name)); - }} - shouldShowRightIcon - /> - - )} + + { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + )} - {!isThereAnyAccountingConnection && ( - { - if (shouldPreventDisableOrDelete) { - setIsCannotDeleteOrDisableLastCategoryModalVisible(true); - return; - } - setDeleteCategoryConfirmModalVisible(true); - }} - /> - )} diff --git a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx index c79e070306e2a..668e90f4d6ccb 100644 --- a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx +++ b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -8,8 +8,10 @@ import Section from '@components/Section'; import useCardFeeds from '@hooks/useCardFeeds'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setPolicyCategoryAttendeesRequired} from '@libs/actions/Policy/Category'; import {getCashExpenseReimbursableMode, setPolicyAttendeeTrackingEnabled, setPolicyRequireCompanyCardsEnabled, setWorkspaceEReceiptsEnabled} from '@libs/actions/Policy/Policy'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -17,6 +19,7 @@ import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOpt import type {ThemeStyles} from '@styles/index'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -72,9 +75,27 @@ function IndividualExpenseRulesSection({policyID}: IndividualExpenseRulesSection const policy = usePolicy(policyID); const [cardFeeds] = useCardFeeds(policyID); const {environmentURL} = useEnvironment(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + const handleAttendeeTrackingToggle = useCallback( + (newValue: boolean) => { + // When disabling attendee tracking, disable areAttendeesRequired on all categories + // that have it enabled in order to avoid BE validation errors + if (!newValue && policyCategories) { + for (const [categoryName, category] of Object.entries(policyCategories)) { + if (!category?.areAttendeesRequired) { + continue; + } + setPolicyCategoryAttendeesRequired(policyID, categoryName, false, policyCategories); + } + } + setPolicyAttendeeTrackingEnabled(policyID, newValue); + }, + [policyID, policyCategories], + ); + const maxExpenseAmountNoReceiptText = useMemo(() => { if (policy?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE) { return ''; @@ -180,7 +201,7 @@ function IndividualExpenseRulesSection({policyID}: IndividualExpenseRulesSection // For backwards compatibility with Expensify Classic, we assume that Attendee Tracking is enabled by default on // Control policies if the policy does not contain the attribute - const isAttendeeTrackingEnabled = policy?.isAttendeeTrackingEnabled ?? true; + const isAttendeeTrackingEnabled = policy?.isAttendeeTrackingEnabled ?? false; return (
setPolicyAttendeeTrackingEnabled(policyID, !isAttendeeTrackingEnabled)} + onToggle={() => handleAttendeeTrackingToggle(!isAttendeeTrackingEnabled)} pendingAction={policy?.pendingFields?.isAttendeeTrackingEnabled} /> diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts index 90aa92ca93672..4f0848dde72fa 100644 --- a/src/types/onyx/PolicyCategory.ts +++ b/src/types/onyx/PolicyCategory.ts @@ -48,6 +48,9 @@ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Max expense amount with no receipt violation */ maxAmountNoReceipt?: number | null; + + /** If true, not providing attendees for a transaction with this category will trigger a violation */ + areAttendeesRequired?: boolean; }>; /** Record of policy categories, indexed by their name */ diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 600411679bf6d..274bcc3c6be8c 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -9,6 +9,12 @@ import type {Policy, PolicyCategories, PolicyTagLists, Report, Transaction, Tran import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import {translateLocal} from '../utils/TestHelper'; +// Mock getCurrentUserEmail from Report actions +const MOCK_CURRENT_USER_EMAIL = 'test@expensify.com'; +jest.mock('@libs/actions/Report', () => ({ + getCurrentUserEmail: jest.fn(() => MOCK_CURRENT_USER_EMAIL), +})); + const categoryOutOfPolicyViolation = { name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -599,6 +605,203 @@ describe('getViolationsOnyxData', () => { expect(result.value).toEqual(expect.arrayContaining([missingDepartmentTag, missingRegionTag, missingProjectTag])); }); }); + + describe('missingAttendees violation', () => { + const missingAttendeesViolation = { + name: CONST.VIOLATIONS.MISSING_ATTENDEES, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }; + + const ownerAccountID = 123; + const otherAccountID = 456; + + let iouReport: Report; + + beforeEach(() => { + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.isAttendeeTrackingEnabled = true; + policyCategories = { + Meals: { + name: 'Meals', + enabled: true, + areAttendeesRequired: true, + }, + }; + transaction.category = 'Meals'; + iouReport = { + reportID: '1234', + ownerAccountID, + } as Report; + }); + + it('should add missingAttendees violation when no attendees are present', () => { + transaction.comment = {attendees: []}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should add missingAttendees violation when only owner is an attendee', () => { + transaction.comment = { + attendees: [{email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add missingAttendees violation when there is at least one non-owner attendee', () => { + transaction.comment = { + attendees: [ + {email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}, + {email: 'other@example.com', displayName: 'Other', avatarUrl: '', accountID: otherAccountID}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should remove missingAttendees violation when attendees are added', () => { + transactionViolations = [missingAttendeesViolation]; + transaction.comment = { + attendees: [ + {email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}, + {email: 'other@example.com', displayName: 'Other', avatarUrl: '', accountID: otherAccountID}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add missingAttendees violation when attendee tracking is disabled', () => { + policy.isAttendeeTrackingEnabled = false; + transaction.comment = {attendees: []}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add missingAttendees violation when category does not require attendees', () => { + policyCategories.Meals.areAttendeesRequired = false; + transaction.comment = {attendees: []}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + describe('optimistic / offline scenarios (iouReport is undefined)', () => { + // In offline scenarios, iouReport is undefined so we can't get ownerAccountID. + // The code falls back to using getCurrentUserEmail() to identify the owner by login/email. + it('should correctly calculate violation when iouReport is undefined but attendees have matching email', () => { + // When iouReport is undefined, we use getCurrentUserEmail() as fallback + // If only the current user (matching MOCK_CURRENT_USER_EMAIL) is an attendee, violation should show + transactionViolations = []; + transaction.comment = { + attendees: [{email: MOCK_CURRENT_USER_EMAIL, displayName: 'Test User', avatarUrl: ''}], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should be added since the only attendee is the current user (owner) + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add violation when iouReport is undefined but there are non-owner attendees (by email)', () => { + // When there are attendees with different emails than the current user, no violation + transactionViolations = []; + transaction.comment = { + attendees: [ + {email: MOCK_CURRENT_USER_EMAIL, displayName: 'Test User', avatarUrl: ''}, + {email: 'other@example.com', displayName: 'Other User', avatarUrl: ''}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should NOT be added since there's a non-owner attendee + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should remove violation when non-owner attendee is added (offline)', () => { + // If violation existed and a non-owner attendee is added, violation should be removed + transactionViolations = [missingAttendeesViolation]; + transaction.comment = { + attendees: [ + {email: MOCK_CURRENT_USER_EMAIL, displayName: 'Test User', avatarUrl: ''}, + {email: 'other@example.com', displayName: 'Other User', avatarUrl: ''}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should be removed + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should preserve violation when only owner attendee remains (offline)', () => { + // If violation existed and only owner attendee remains, violation stays + transactionViolations = [missingAttendeesViolation]; + transaction.comment = { + attendees: [{email: MOCK_CURRENT_USER_EMAIL, displayName: 'Test User', avatarUrl: ''}], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should be preserved + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + }); + + describe('fallback case (iouReport undefined AND getCurrentUserEmail returns falsy)', () => { + // This tests the edge case where we cannot identify the owner at all: + // - ownerAccountID is undefined (iouReport unavailable) + // - getCurrentUserEmail() returns falsy (no current user email) + // In this case, we assume owner is one of the attendees, so we need at least 2 attendees + // for there to be a non-owner attendee. + + beforeEach(() => { + // Mock getCurrentUserEmail to return empty string + jest.spyOn(require('@libs/actions/Report'), 'getCurrentUserEmail').mockReturnValue(''); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should add missingAttendees violation when no attendees are present (can't identify owner)", () => { + transactionViolations = []; + transaction.comment = {attendees: []}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // With 0 attendees, attendeesMinusOwnerCount = Math.max(0, 0 - 1) = 0, violation should be added + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should add missingAttendees violation when only 1 attendee exists (assumed to be owner)', () => { + transactionViolations = []; + transaction.comment = { + attendees: [{email: 'anyone@example.com', displayName: 'Someone', avatarUrl: ''}], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // With 1 attendee, attendeesMinusOwnerCount = Math.max(0, 1 - 1) = 0, violation should be added + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add missingAttendees violation when 2+ attendees exist (assumes owner is one of them)', () => { + transactionViolations = []; + transaction.comment = { + attendees: [ + {email: 'person1@example.com', displayName: 'Person 1', avatarUrl: ''}, + {email: 'person2@example.com', displayName: 'Person 2', avatarUrl: ''}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // With 2 attendees, attendeesMinusOwnerCount = Math.max(0, 2 - 1) = 1, no violation + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should remove missingAttendees violation when second attendee is added', () => { + transactionViolations = [missingAttendeesViolation]; + transaction.comment = { + attendees: [ + {email: 'person1@example.com', displayName: 'Person 1', avatarUrl: ''}, + {email: 'person2@example.com', displayName: 'Person 2', avatarUrl: ''}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should be removed since we now have 2 attendees + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + }); + }); }); const getFakeTransaction = (transactionID: string, comment?: Transaction['comment']) => ({