diff --git a/src/CONST.ts b/src/CONST.ts index 0c98645511d4..15b89a8e9940 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3055,6 +3055,42 @@ const CONST = { CAROUSEL: 3, }, + VIOLATIONS: { + ALL_TAG_LEVELS_REQUIRED: 'allTagLevelsRequired', + AUTO_REPORTED_REJECTED_EXPENSE: 'autoReportedRejectedExpense', + BILLABLE_EXPENSE: 'billableExpense', + CASH_EXPENSE_WITH_NO_RECEIPT: 'cashExpenseWithNoReceipt', + CATEGORY_OUT_OF_POLICY: 'categoryOutOfPolicy', + CONVERSION_SURCHARGE: 'conversionSurcharge', + CUSTOM_UNIT_OUT_OF_POLICY: 'customUnitOutOfPolicy', + DUPLICATED_TRANSACTION: 'duplicatedTransaction', + FIELD_REQUIRED: 'fieldRequired', + FUTURE_DATE: 'futureDate', + INVOICE_MARKUP: 'invoiceMarkup', + MAX_AGE: 'maxAge', + MISSING_CATEGORY: 'missingCategory', + MISSING_COMMENT: 'missingComment', + MISSING_TAG: 'missingTag', + MODIFIED_AMOUNT: 'modifiedAmount', + MODIFIED_DATE: 'modifiedDate', + NON_EXPENSIWORKS_EXPENSE: 'nonExpensiworksExpense', + OVER_AUTO_APPROVAL_LIMIT: 'overAutoApprovalLimit', + OVER_CATEGORY_LIMIT: 'overCategoryLimit', + OVER_LIMIT: 'overLimit', + OVER_LIMIT_ATTENDEE: 'overLimitAttendee', + PER_DAY_LIMIT: 'perDayLimit', + RECEIPT_NOT_SMART_SCANNED: 'receiptNotSmartScanned', + RECEIPT_REQUIRED: 'receiptRequired', + RTER: 'rter', + SMARTSCAN_FAILED: 'smartscanFailed', + SOME_TAG_LEVELS_REQUIRED: 'someTagLevelsRequired', + TAG_OUT_OF_POLICY: 'tagOutOfPolicy', + TAX_AMOUNT_CHANGED: 'taxAmountChanged', + TAX_OUT_OF_POLICY: 'taxOutOfPolicy', + TAX_RATE_CHANGED: 'taxRateChanged', + TAX_REQUIRED: 'taxRequired', + }, + /** Context menu types */ CONTEXT_MENU_TYPES: { LINK: 'LINK', diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index 514dc71ffe2c..f5b3353b2f5f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -1,7 +1,7 @@ import lodashGet from 'lodash/get'; import lodashValues from 'lodash/values'; import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import categoryPropTypes from '@components/categoryPropTypes'; @@ -14,12 +14,14 @@ import Switch from '@components/Switch'; import tagPropTypes from '@components/tagPropTypes'; import Text from '@components/Text'; import transactionPropTypes from '@components/transactionPropTypes'; +import ViolationMessages from '@components/ViolationMessages'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useViolations from '@hooks/useViolations'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CardUtils from '@libs/CardUtils'; import compose from '@libs/compose'; @@ -41,6 +43,32 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import ReportActionItemImage from './ReportActionItemImage'; +const violationNames = lodashValues(CONST.VIOLATIONS); + +const transactionViolationPropType = PropTypes.shape({ + type: PropTypes.string.isRequired, + name: PropTypes.oneOf(violationNames).isRequired, + data: PropTypes.shape({ + rejectedBy: PropTypes.string, + rejectReason: PropTypes.string, + amount: PropTypes.string, + surcharge: PropTypes.number, + invoiceMarkup: PropTypes.number, + maxAge: PropTypes.number, + tagName: PropTypes.string, + formattedLimitAmount: PropTypes.string, + categoryLimit: PropTypes.string, + limit: PropTypes.string, + category: PropTypes.string, + brokenBankConnection: PropTypes.bool, + isAdmin: PropTypes.bool, + email: PropTypes.string, + isTransactionOlderThan7Days: PropTypes.bool, + member: PropTypes.string, + taxName: PropTypes.string, + }), +}); + const propTypes = { /** The report currently being looked at */ report: reportPropTypes.isRequired, @@ -61,6 +89,9 @@ const propTypes = { /** The transaction associated with the transactionThread */ transaction: transactionPropTypes, + /** Violations detected in this transaction */ + transactionViolations: PropTypes.arrayOf(transactionViolationPropType), + /** Collection of tags attached to a policy */ policyTags: tagPropTypes, @@ -76,10 +107,11 @@ const defaultProps = { currency: CONST.CURRENCY.USD, comment: {comment: ''}, }, + transactionViolations: [], policyTags: {}, }; -function MoneyRequestView({report, parentReport, parentReportActions, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy}) { +function MoneyRequestView({report, parentReport, parentReportActions, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy, transactionViolations}) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -131,6 +163,9 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagsList))); const shouldShowBillable = isPolicyExpenseChat && (transactionBillable || !lodashGet(policy, 'disabledFields.defaultBillable', true)); + const {getViolationsForField} = useViolations(transactionViolations); + const hasViolations = useCallback((field) => canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]); + let amountDescription = `${translate('iou.amount')}`; if (isCardTransaction) { @@ -198,6 +233,7 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT))} /> )} + {canUseViolations && } Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} - brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} /> + {canUseViolations && } Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} + brickRoadIndicator={hasViolations('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} numberOfLinesTitle={0} /> + {canUseViolations && } {isDistanceRequest ? ( @@ -245,9 +284,10 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} - brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} error={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} /> + {canUseViolations && } )} @@ -258,9 +298,10 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate shouldShowRightIcon={canEdit && !isSettled} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} - brickRoadIndicator={hasErrors && transactionDate === '' ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} /> + {canUseViolations && } {shouldShowCategory && ( @@ -271,7 +312,9 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))} + brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} /> + {canUseViolations && } )} {shouldShowTag && ( @@ -283,7 +326,9 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.TAG))} + brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} /> + {canUseViolations && } )} {isCardTransaction && ( @@ -295,15 +340,24 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate /> )} + {shouldShowBillable && ( - - {translate('common.billable')} - IOU.editMoneyRequest(transaction, report.reportID, {billable: value})} - /> - + <> + + {translate('common.billable')} + IOU.editMoneyRequest(transaction, report.reportID, {billable: value})} + /> + + {hasViolations('billable') && ( + + )} + )} { + const parentReportAction = ReportActionsUtils.getParentReportAction(report); + const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], 0); + return `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`; + }, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`, + }, }), )(MoneyRequestView); diff --git a/src/components/ViolationMessages.tsx b/src/components/ViolationMessages.tsx new file mode 100644 index 000000000000..8eb555184596 --- /dev/null +++ b/src/components/ViolationMessages.tsx @@ -0,0 +1,26 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ViolationsUtils from '@libs/ViolationsUtils'; +import type {TransactionViolation} from '@src/types/onyx'; +import Text from './Text'; + +export default function ViolationMessages({violations, isLast}: {violations: TransactionViolation[]; isLast?: boolean}) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const violationMessages = useMemo(() => violations.map((violation) => [violation.name, ViolationsUtils.getViolationTranslation(violation, translate)]), [translate, violations]); + + return ( + + {violationMessages.map(([name, message]) => ( + + {message} + + ))} + + ); +} diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 0f43abdff6e2..76d48158237b 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -2,12 +2,12 @@ import {useCallback, useMemo} from 'react'; import type {TransactionViolation, ViolationName} from '@src/types/onyx'; /** - * Names of Fields where violations can occur + * Names of Fields where violations can occur. */ type ViolationField = 'amount' | 'billable' | 'category' | 'comment' | 'date' | 'merchant' | 'receipt' | 'tag' | 'tax'; /** - * Map from Violation Names to the field where that violation can occur + * Map from Violation Names to the field where that violation can occur. */ const violationFields: Record = { allTagLevelsRequired: 'tag', @@ -60,13 +60,12 @@ function useViolations(violations: TransactionViolation[]) { return violationGroups ?? new Map(); }, [violations]); - const hasViolations = useCallback((field: ViolationField) => Boolean(violationsByField.get(field)?.length), [violationsByField]); const getViolationsForField = useCallback((field: ViolationField) => violationsByField.get(field) ?? [], [violationsByField]); return { - hasViolations, getViolationsForField, }; } export default useViolations; +export type {ViolationField}; diff --git a/src/languages/en.ts b/src/languages/en.ts index e223dd0a9aaf..6e177c1df141 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -74,6 +74,20 @@ import type { UpdatedTheDistanceParams, UpdatedTheRequestParams, UserIsAlreadyMemberParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsOverAutoApprovalLimitParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, WaitingOnBankAccountParams, WalletProgramParams, WelcomeEnterMagicCodeParams, @@ -2035,38 +2049,49 @@ export default { copyReferralLink: 'Copy invite link', }, violations: { - allTagLevelsRequired: 'dummy.violations.allTagLevelsRequired', - autoReportedRejectedExpense: 'dummy.violations.autoReportedRejectedExpense', - billableExpense: 'dummy.violations.billableExpense', - cashExpenseWithNoReceipt: 'dummy.violations.cashExpenseWithNoReceipt', - categoryOutOfPolicy: 'dummy.violations.categoryOutOfPolicy', - conversionSurcharge: 'dummy.violations.conversionSurcharge', - customUnitOutOfPolicy: 'dummy.violations.customUnitOutOfPolicy', - duplicatedTransaction: 'dummy.violations.duplicatedTransaction', - fieldRequired: 'dummy.violations.fieldRequired', - futureDate: 'dummy.violations.futureDate', - invoiceMarkup: 'dummy.violations.invoiceMarkup', - maxAge: 'dummy.violations.maxAge', - missingCategory: 'dummy.violations.missingCategory', - missingComment: 'dummy.violations.missingComment', - missingTag: 'dummy.violations.missingTag', - modifiedAmount: 'dummy.violations.modifiedAmount', - modifiedDate: 'dummy.violations.modifiedDate', - nonExpensiworksExpense: 'dummy.violations.nonExpensiworksExpense', - overAutoApprovalLimit: 'dummy.violations.overAutoApprovalLimit', - overCategoryLimit: 'dummy.violations.overCategoryLimit', - overLimit: 'dummy.violations.overLimit', - overLimitAttendee: 'dummy.violations.overLimitAttendee', - perDayLimit: 'dummy.violations.perDayLimit', - receiptNotSmartScanned: 'dummy.violations.receiptNotSmartScanned', - receiptRequired: 'dummy.violations.receiptRequired', - rter: 'dummy.violations.rter', - smartscanFailed: 'dummy.violations.smartscanFailed', - someTagLevelsRequired: 'dummy.violations.someTagLevelsRequired', - tagOutOfPolicy: 'dummy.violations.tagOutOfPolicy', - taxAmountChanged: 'dummy.violations.taxAmountChanged', - taxOutOfPolicy: 'dummy.violations.taxOutOfPolicy', - taxRateChanged: 'dummy.violations.taxRateChanged', - taxRequired: 'dummy.violations.taxRequired', + allTagLevelsRequired: 'All tags required', + autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, + billableExpense: 'Billable no longer valid', + cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required over ${amount}`, + categoryOutOfPolicy: 'Category no longer valid', + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`, + customUnitOutOfPolicy: 'Unit no longer valid', + duplicatedTransaction: 'Potential duplicate', + fieldRequired: 'Report fields are required', + futureDate: 'Future date not allowed', + invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Marked up by ${invoiceMarkup}%`, + maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, + missingCategory: 'Missing category', + missingComment: 'Description required for selected category', + missingTag: ({tagName}: ViolationsMissingTagParams) => `Missing ${tagName ?? 'tag'}`, + modifiedAmount: 'Amount greater than scanned receipt', + modifiedDate: 'Date differs from scanned receipt', + nonExpensiworksExpense: 'Non-Expensiworks expense', + overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Expense exceeds auto approval limit of ${formattedLimitAmount}`, + overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${categoryLimit}/person category limit`, + overLimit: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, + overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, + perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Amount over daily ${limit}/person category limit`, + receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', + receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Receipt required over ${amount} ${category ? ' category limit' : ''}`, + rter: ({brokenBankConnection, email, isAdmin, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { + if (brokenBankConnection) { + return isAdmin + ? `Can't auto-match receipt due to broken bank connection which ${email} needs to fix` + : "Can't auto-match receipt due to broken bank connection which you need to fix"; + } + if (!isTransactionOlderThan7Days) { + return isAdmin ? `Ask ${member} to mark as a cash or wait 7 days and try again` : 'Awaiting merge with card transaction.'; + } + + return ''; + }, + smartscanFailed: 'Receipt scanning failed. Enter details manually.', + someTagLevelsRequired: 'Missing tag', + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `${tagName ?? ''} no longer valid`, + taxAmountChanged: 'Tax amount was modified', + taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? ''} no longer valid`, + taxRateChanged: 'Tax rate was modified', + taxRequired: 'Missing tax rate', }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index 42743f43a098..990554b0b502 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -73,6 +73,20 @@ import type { UpdatedTheDistanceParams, UpdatedTheRequestParams, UserIsAlreadyMemberParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsOverAutoApprovalLimitParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, WaitingOnBankAccountParams, WalletProgramParams, WelcomeEnterMagicCodeParams, @@ -2522,38 +2536,50 @@ export default { copyReferralLink: 'Copiar enlace de invitación', }, violations: { - allTagLevelsRequired: 'dummy.violations.allTagLevelsRequired', - autoReportedRejectedExpense: 'dummy.violations.autoReportedRejectedExpense', - billableExpense: 'dummy.violations.billableExpense', - cashExpenseWithNoReceipt: 'dummy.violations.cashExpenseWithNoReceipt', - categoryOutOfPolicy: 'dummy.violations.categoryOutOfPolicy', - conversionSurcharge: 'dummy.violations.conversionSurcharge', - customUnitOutOfPolicy: 'dummy.violations.customUnitOutOfPolicy', - duplicatedTransaction: 'dummy.violations.duplicatedTransaction', - fieldRequired: 'dummy.violations.fieldRequired', - futureDate: 'dummy.violations.futureDate', - invoiceMarkup: 'dummy.violations.invoiceMarkup', - maxAge: 'dummy.violations.maxAge', - missingCategory: 'dummy.violations.missingCategory', - missingComment: 'dummy.violations.missingComment', - missingTag: 'dummy.violations.missingTag', - modifiedAmount: 'dummy.violations.modifiedAmount', - modifiedDate: 'dummy.violations.modifiedDate', - nonExpensiworksExpense: 'dummy.violations.nonExpensiworksExpense', - overAutoApprovalLimit: 'dummy.violations.overAutoApprovalLimit', - overCategoryLimit: 'dummy.violations.overCategoryLimit', - overLimit: 'dummy.violations.overLimit', - overLimitAttendee: 'dummy.violations.overLimitAttendee', - perDayLimit: 'dummy.violations.perDayLimit', - receiptNotSmartScanned: 'dummy.violations.receiptNotSmartScanned', - receiptRequired: 'dummy.violations.receiptRequired', - rter: 'dummy.violations.rter', - smartscanFailed: 'dummy.violations.smartscanFailed', - someTagLevelsRequired: 'dummy.violations.someTagLevelsRequired', - tagOutOfPolicy: 'dummy.violations.tagOutOfPolicy', - taxAmountChanged: 'dummy.violations.taxAmountChanged', - taxOutOfPolicy: 'dummy.violations.taxOutOfPolicy', - taxRateChanged: 'dummy.violations.taxRateChanged', - taxRequired: 'dummy.violations.taxRequired', + allTagLevelsRequired: 'Todas las etiquetas son obligatorias', + autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, + billableExpense: 'La opción facturable ya no es válida', + cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para montos mayores a ${amount}`, + categoryOutOfPolicy: 'La categoría ya no es válida', + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `${surcharge}% de recargo aplicado`, + customUnitOutOfPolicy: 'Unidad ya no es válida', + duplicatedTransaction: 'Potencial duplicado', + fieldRequired: 'Los campos del informe son obligatorios', + futureDate: 'Fecha futura no permitida', + invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Incrementado un ${invoiceMarkup}%`, + maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Fecha de más de ${maxAge} días`, + missingCategory: 'Falta categoría', + missingComment: 'Descripción obligatoria para categoría seleccionada', + missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName}`, + modifiedAmount: 'Importe superior al del recibo escaneado', + modifiedDate: 'Fecha difiere del recibo escaneado', + nonExpensiworksExpense: 'Gasto no es de Expensiworks', + overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Importe supera el límite de aprobación automática de ${formattedLimitAmount}`, + overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría de ${categoryLimit}/persona`, + overLimit: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, + overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, + perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría de ${limit}/persona`, + receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma su exactitud', + receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Recibo obligatorio para importes sobre ${category ? 'el limite de la categoría de ' : ''}${amount}`, + rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { + if (brokenBankConnection) { + return isAdmin + ? `No se puede adjuntar recibo debido a una conexión con su banco que ${email} necesita arreglar` + : 'No se puede adjuntar recibo debido a una conexión con su banco que necesitas arreglar'; + } + if (!isTransactionOlderThan7Days) { + return isAdmin + ? `Pídele a ${member} que marque la transacción como efectivo o espera 7 días e intenta de nuevo` + : 'Esperando adjuntar automáticamente a transacción de tarjeta de crédito'; + } + return ''; + }, + smartscanFailed: 'No se pudo escanear el recibo. Introduce los datos manualmente', + someTagLevelsRequired: 'Falta etiqueta', + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `Le etiqueta ${tagName} ya no es válida`, + taxAmountChanged: 'El importe del impuesto fue modificado', + taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName} ya no es válido`, + taxRateChanged: 'La tasa de impuesto fue modificada', + taxRequired: 'Falta tasa de impuesto', }, } satisfies EnglishTranslation; diff --git a/src/languages/types.ts b/src/languages/types.ts index dd2d339858b0..5b6e56a38689 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -209,6 +209,40 @@ type TagSelectionParams = {tagName: string}; type WalletProgramParams = {walletProgram: string}; +type ViolationsAutoReportedRejectedExpenseParams = {rejectedBy: string; rejectReason: string}; + +type ViolationsCashExpenseWithNoReceiptParams = {amount: string}; + +type ViolationsConversionSurchargeParams = {surcharge?: number}; + +type ViolationsInvoiceMarkupParams = {invoiceMarkup?: number}; + +type ViolationsMaxAgeParams = {maxAge: number}; + +type ViolationsMissingTagParams = {tagName?: string}; + +type ViolationsOverAutoApprovalLimitParams = {formattedLimitAmount: string}; + +type ViolationsOverCategoryLimitParams = {categoryLimit: string}; + +type ViolationsOverLimitParams = {amount: string}; + +type ViolationsPerDayLimitParams = {limit: string}; + +type ViolationsReceiptRequiredParams = {amount: string; category?: string}; + +type ViolationsRterParams = { + brokenBankConnection: boolean; + isAdmin: boolean; + email?: string; + isTransactionOlderThan7Days: boolean; + member?: string; +}; + +type ViolationsTagOutOfPolicyParams = {tagName?: string}; + +type ViolationsTaxOutOfPolicyParams = {taxName?: string}; + type TaskCreatedActionParams = {title: string}; /* Translation Object types */ @@ -250,87 +284,101 @@ type TranslationFlatObject = { }; export type { - TranslationBase, - TranslationPaths, - EnglishTranslation, - TranslationFlatObject, + ApprovedAmountParams, AddressLineParams, - CharacterLimitParams, - MaxParticipantsReachedParams, - ZipCodeExampleFormatParams, - LoggedInAsParams, - NewFaceEnterMagicCodeParams, - WelcomeEnterMagicCodeParams, AlreadySignedInParams, - GoBackMessageParams, - LocalTimeParams, - EditActionParams, - DeleteActionParams, - DeleteConfirmationParams, - BeginningOfChatHistoryDomainRoomPartOneParams, + AmountEachParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartTwo, - WelcomeToRoomParams, - ReportArchiveReasonsClosedParams, - ReportArchiveReasonsMergedParams, - ReportArchiveReasonsRemovedFromPolicyParams, - ReportArchiveReasonsPolicyDeletedParams, - RequestCountParams, - SettleExpensifyCardParams, - RequestAmountParams, - RequestedAmountMessageParams, - SplitAmountParams, + BeginningOfChatHistoryDomainRoomPartOneParams, + CanceledRequestParams, + CharacterLimitParams, + ConfirmThatParams, + DateShouldBeAfterParams, + DateShouldBeBeforeParams, + DeleteActionParams, + DeleteConfirmationParams, DidSplitAmountMessageParams, - AmountEachParams, + EditActionParams, + EnglishTranslation, + EnterMagicCodeParams, + FormattedMaxLengthParams, + GoBackMessageParams, + GoToRoomParams, + IncorrectZipFormatParams, + InstantSummaryParams, + LocalTimeParams, + LoggedInAsParams, + ManagerApprovedAmountParams, + ManagerApprovedParams, + MaxParticipantsReachedParams, + NewFaceEnterMagicCodeParams, + NoLongerHaveAccessParams, + NotAllowedExtensionParams, + NotYouParams, + OOOEventSummaryFullDayParams, + OOOEventSummaryPartialDayParams, + OurEmailProviderParams, + PaidElsewhereWithAmountParams, + PaidWithExpensifyWithAmountParams, + ParentNavigationSummaryParams, PayerOwesAmountParams, PayerOwesParams, PayerPaidAmountParams, PayerPaidParams, - ApprovedAmountParams, - ManagerApprovedParams, - ManagerApprovedAmountParams, PayerSettledParams, - WaitingOnBankAccountParams, - CanceledRequestParams, - SettledAfterAddedBankAccountParams, - PaidElsewhereWithAmountParams, - PaidWithExpensifyWithAmountParams, - ThreadRequestReportNameParams, - ThreadSentMoneyReportNameParams, - SizeExceededParams, + RemovedTheRequestParams, + RenamedRoomActionParams, + ReportArchiveReasonsClosedParams, + ReportArchiveReasonsMergedParams, + ReportArchiveReasonsPolicyDeletedParams, + ReportArchiveReasonsRemovedFromPolicyParams, + RequestAmountParams, + RequestCountParams, + RequestedAmountMessageParams, ResolutionConstraintsParams, - NotAllowedExtensionParams, - EnterMagicCodeParams, - TransferParams, - InstantSummaryParams, - NotYouParams, - DateShouldBeBeforeParams, - DateShouldBeAfterParams, - IncorrectZipFormatParams, - WeSentYouMagicSignInLinkParams, - ToValidateLoginParams, - NoLongerHaveAccessParams, - OurEmailProviderParams, - ConfirmThatParams, - UntilTimeParams, - StepCounterParams, - UserIsAlreadyMemberParams, - GoToRoomParams, - WelcomeNoteParams, RoomNameReservedErrorParams, - RenamedRoomActionParams, RoomRenamedToParams, - OOOEventSummaryFullDayParams, - OOOEventSummaryPartialDayParams, - ParentNavigationSummaryParams, + SetTheDistanceParams, SetTheRequestParams, - UpdatedTheRequestParams, - RemovedTheRequestParams, - FormattedMaxLengthParams, + SettleExpensifyCardParams, + SettledAfterAddedBankAccountParams, + SizeExceededParams, + SplitAmountParams, + StepCounterParams, TagSelectionParams, - SetTheDistanceParams, + TaskCreatedActionParams, + ThreadRequestReportNameParams, + ThreadSentMoneyReportNameParams, + ToValidateLoginParams, + TransferParams, + TranslationBase, + TranslationFlatObject, + TranslationPaths, + UntilTimeParams, UpdatedTheDistanceParams, + UpdatedTheRequestParams, + UserIsAlreadyMemberParams, + ViolationsAutoReportedRejectedExpenseParams, + ViolationsCashExpenseWithNoReceiptParams, + ViolationsConversionSurchargeParams, + ViolationsInvoiceMarkupParams, + ViolationsMaxAgeParams, + ViolationsMissingTagParams, + ViolationsOverAutoApprovalLimitParams, + ViolationsOverCategoryLimitParams, + ViolationsOverLimitParams, + ViolationsPerDayLimitParams, + ViolationsReceiptRequiredParams, + ViolationsRterParams, + ViolationsTagOutOfPolicyParams, + ViolationsTaxOutOfPolicyParams, + WaitingOnBankAccountParams, WalletProgramParams, - TaskCreatedActionParams, + WeSentYouMagicSignInLinkParams, + WelcomeEnterMagicCodeParams, + WelcomeNoteParams, + WelcomeToRoomParams, + ZipCodeExampleFormatParams, }; diff --git a/src/libs/ViolationsUtils.ts b/src/libs/ViolationsUtils.ts index 748f0ed86b7f..2637686e726b 100644 --- a/src/libs/ViolationsUtils.ts +++ b/src/libs/ViolationsUtils.ts @@ -1,7 +1,9 @@ import reject from 'lodash/reject'; import Onyx from 'react-native-onyx'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyCategories, PolicyTags, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {Phrase, PhraseParameters} from './Localize'; const ViolationsUtils = { /** @@ -80,6 +82,104 @@ const ViolationsUtils = { value: newTransactionViolations, }; }, + /** + * Gets the translated message for each violation type. + * + * Necessary because `translate` throws a type error if you attempt to pass it a template strings, when the + * possible values could be either translation keys that resolve to strings or translation keys that resolve to + * functions. + */ + getViolationTranslation( + violation: TransactionViolation, + translate: (phraseKey: TKey, ...phraseParameters: PhraseParameters>) => string, + ): string { + switch (violation.name) { + case 'allTagLevelsRequired': + return translate('violations.allTagLevelsRequired'); + case 'autoReportedRejectedExpense': + return translate('violations.autoReportedRejectedExpense', { + rejectedBy: violation.data?.rejectedBy ?? '', + rejectReason: violation.data?.rejectReason ?? '', + }); + case 'billableExpense': + return translate('violations.billableExpense'); + case 'cashExpenseWithNoReceipt': + return translate('violations.cashExpenseWithNoReceipt', {amount: violation.data?.amount ?? ''}); + case 'categoryOutOfPolicy': + return translate('violations.categoryOutOfPolicy'); + case 'conversionSurcharge': + return translate('violations.conversionSurcharge', {surcharge: violation.data?.surcharge}); + case 'customUnitOutOfPolicy': + return translate('violations.customUnitOutOfPolicy'); + case 'duplicatedTransaction': + return translate('violations.duplicatedTransaction'); + case 'fieldRequired': + return translate('violations.fieldRequired'); + case 'futureDate': + return translate('violations.futureDate'); + case 'invoiceMarkup': + return translate('violations.invoiceMarkup', {invoiceMarkup: violation.data?.invoiceMarkup}); + case 'maxAge': + return translate('violations.maxAge', {maxAge: violation.data?.maxAge ?? 0}); + case 'missingCategory': + return translate('violations.missingCategory'); + case 'missingComment': + return translate('violations.missingComment'); + case 'missingTag': + return translate('violations.missingTag', {tagName: violation.data?.tagName}); + case 'modifiedAmount': + return translate('violations.modifiedAmount'); + case 'modifiedDate': + return translate('violations.modifiedDate'); + case 'nonExpensiworksExpense': + return translate('violations.nonExpensiworksExpense'); + case 'overAutoApprovalLimit': + return translate('violations.overAutoApprovalLimit', {formattedLimitAmount: violation.data?.formattedLimitAmount ?? ''}); + case 'overCategoryLimit': + return translate('violations.overCategoryLimit', {categoryLimit: violation.data?.categoryLimit ?? ''}); + case 'overLimit': + return translate('violations.overLimit', {amount: violation.data?.amount ?? ''}); + case 'overLimitAttendee': + return translate('violations.overLimitAttendee', {amount: violation.data?.amount ?? ''}); + case 'perDayLimit': + return translate('violations.perDayLimit', {limit: violation.data?.limit ?? ''}); + case 'receiptNotSmartScanned': + return translate('violations.receiptNotSmartScanned'); + case 'receiptRequired': + return translate('violations.receiptRequired', { + amount: violation.data?.amount ?? '0', + category: violation.data?.category ?? '', + }); + case 'rter': + return translate('violations.rter', { + brokenBankConnection: violation.data?.brokenBankConnection ?? false, + isAdmin: violation.data?.isAdmin ?? false, + email: violation.data?.email, + isTransactionOlderThan7Days: Boolean(violation.data?.isTransactionOlderThan7Days), + member: violation.data?.member, + }); + case 'smartscanFailed': + return translate('violations.smartscanFailed'); + case 'someTagLevelsRequired': + return translate('violations.someTagLevelsRequired'); + case 'tagOutOfPolicy': + return translate('violations.tagOutOfPolicy', {tagName: violation.data?.tagName}); + case 'taxAmountChanged': + return translate('violations.taxAmountChanged'); + case 'taxOutOfPolicy': + return translate('violations.taxOutOfPolicy', {taxName: violation.data?.taxName}); + case 'taxRateChanged': + return translate('violations.taxRateChanged'); + case 'taxRequired': + return translate('violations.taxRequired'); + default: + // The interpreter should never get here because the switch cases should be exhaustive. + // If typescript is showing an error on the assertion below it means the switch statement is out of + // sync with the `ViolationNames` type, and one or the other needs to be updated. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return violation.name as never; + } + }, }; export default ViolationsUtils; diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 27d70529dd8a..6def4858229f 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -223,6 +223,10 @@ export default { marginTop: 'auto', }, + mtn2: { + marginTop: -8, + }, + mtn6: { marginTop: -24, }, diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts index b6dfb7bbab9a..03d5877bc5b5 100644 --- a/src/types/onyx/PolicyCategory.ts +++ b/src/types/onyx/PolicyCategory.ts @@ -20,5 +20,5 @@ type PolicyCategory = { }; type PolicyCategories = Record; -export default PolicyCategory; -export type {PolicyCategories}; + +export type {PolicyCategory, PolicyCategories}; diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts index 7807dcc00433..58a21dcf4df5 100644 --- a/src/types/onyx/PolicyTag.ts +++ b/src/types/onyx/PolicyTag.ts @@ -12,5 +12,4 @@ type PolicyTag = { type PolicyTags = Record; -export default PolicyTag; -export type {PolicyTags}; +export type {PolicyTag, PolicyTags}; diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts index f7bc5ea1ee8b..dd7a9ef65746 100644 --- a/src/types/onyx/TransactionViolation.ts +++ b/src/types/onyx/TransactionViolation.ts @@ -1,46 +1,34 @@ +import type CONST from '@src/CONST'; + /** - * Names of transaction violations + * Names of violations. + * Derived from `CONST.VIOLATIONS` to maintain a single source of truth. */ -type ViolationName = - | 'allTagLevelsRequired' - | 'autoReportedRejectedExpense' - | 'billableExpense' - | 'cashExpenseWithNoReceipt' - | 'categoryOutOfPolicy' - | 'conversionSurcharge' - | 'customUnitOutOfPolicy' - | 'duplicatedTransaction' - | 'fieldRequired' - | 'futureDate' - | 'invoiceMarkup' - | 'maxAge' - | 'missingCategory' - | 'missingComment' - | 'missingTag' - | 'modifiedAmount' - | 'modifiedDate' - | 'nonExpensiworksExpense' - | 'overAutoApprovalLimit' - | 'overCategoryLimit' - | 'overLimit' - | 'overLimitAttendee' - | 'perDayLimit' - | 'receiptNotSmartScanned' - | 'receiptRequired' - | 'rter' - | 'smartscanFailed' - | 'someTagLevelsRequired' - | 'tagOutOfPolicy' - | 'taxAmountChanged' - | 'taxOutOfPolicy' - | 'taxRateChanged' - | 'taxRequired'; +type ViolationName = (typeof CONST.VIOLATIONS)[keyof typeof CONST.VIOLATIONS]; type TransactionViolation = { type: string; name: ViolationName; userMessage: string; - data?: Record; + data?: { + rejectedBy?: string; + rejectReason?: string; + amount?: string; + surcharge?: number; + invoiceMarkup?: number; + maxAge?: number; + tagName?: string; + formattedLimitAmount?: string; + categoryLimit?: string; + limit?: string; + category?: string; + brokenBankConnection?: boolean; + isAdmin?: boolean; + email?: string; + isTransactionOlderThan7Days?: boolean; + member?: string; + taxName?: string; + }; }; export type {TransactionViolation, ViolationName}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index de71202dcc2a..7bd9c321be5e 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -27,13 +27,11 @@ import type {PersonalDetailsList} from './PersonalDetails'; import type PersonalDetails from './PersonalDetails'; import type PlaidData from './PlaidData'; import type Policy from './Policy'; -import type {PolicyCategories} from './PolicyCategory'; -import type PolicyCategory from './PolicyCategory'; +import type {PolicyCategories, PolicyCategory} from './PolicyCategory'; import type {PolicyMembers} from './PolicyMember'; import type PolicyMember from './PolicyMember'; import type PolicyReportField from './PolicyReportField'; -import type {PolicyTags} from './PolicyTag'; -import type PolicyTag from './PolicyTag'; +import type {PolicyTag, PolicyTags} from './PolicyTag'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; import type RecentlyUsedCategories from './RecentlyUsedCategories'; import type RecentlyUsedReportFields from './RecentlyUsedReportFields';