diff --git a/assets/images/simple-illustrations/simple-illustration__perdiem.svg b/assets/images/simple-illustrations/simple-illustration__perdiem.svg new file mode 100644 index 000000000000..ea5a865a2694 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__perdiem.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CONST.ts b/src/CONST.ts index e0e78f04fe60..11c6b6267476 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -844,6 +844,7 @@ const CONST = { UPWORK_URL: 'https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22', DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works', DEEP_DIVE_ERECEIPTS: 'https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts/', + DEEP_DIVE_PER_DIEM: 'https://community.expensify.com/discussion/4772/how-to-add-a-single-rate-per-diem', GITHUB_URL: 'https://github.com/Expensify/App', TERMS_URL: `${USE_EXPENSIFY_URL}/terms`, PRIVACY_URL: `${USE_EXPENSIFY_URL}/privacy`, @@ -2466,6 +2467,7 @@ const CONST = { ARE_INVOICES_ENABLED: 'areInvoicesEnabled', ARE_TAXES_ENABLED: 'tax', ARE_RULES_ENABLED: 'areRulesEnabled', + ARE_PER_DIEM_RATES_ENABLED: 'arePerDiemRatesEnabled', }, DEFAULT_CATEGORIES: [ 'Advertising', @@ -2632,6 +2634,7 @@ const CONST = { CUSTOM_UNITS: { NAME_DISTANCE: 'Distance', + NAME_PER_DIEM_INTERNATIONAL: 'Per Diem International', DISTANCE_UNIT_MILES: 'mi', DISTANCE_UNIT_KILOMETERS: 'km', MILEAGE_IRS_RATE: 0.67, @@ -6093,6 +6096,14 @@ const CONST = { description: 'workspace.upgrade.rules.description' as const, icon: 'Rules', }, + perDiem: { + id: 'perDiem' as const, + alias: 'per-diem', + name: 'Per diem', + title: 'workspace.upgrade.perDiem.title' as const, + description: 'workspace.upgrade.perDiem.description' as const, + icon: 'PerDiem', + }, }; }, REPORT_FIELD_TYPES: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 45501bf46374..cd94035e0fff 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1273,6 +1273,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, }, + WORKSPACE_PER_DIEM: { + route: 'settings/workspaces/:policyID/per-diem', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem` as const, + }, RULES_CUSTOM_NAME: { route: 'settings/workspaces/:policyID/rules/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index dea0f028e1a0..9b8fe54111cf 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -542,6 +542,7 @@ const SCREENS = { RULES_MAX_EXPENSE_AMOUNT: 'Rules_Max_Expense_Amount', RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default', + PER_DIEM: 'Per_Diem', }, EDIT_REQUEST: { diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 4673b4f269ec..32e063f03109 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -35,6 +35,7 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.SETTINGS.TROUBLESHOOT, SCREENS.SETTINGS.SAVE_THE_WORLD, SCREENS.WORKSPACE.RULES, + SCREENS.WORKSPACE.PER_DIEM, ]; export default WIDE_LAYOUT_INACTIVE_SCREENS; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 0efb65ed7a61..991aaea86513 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -101,6 +101,7 @@ import MoneyWings from '@assets/images/simple-illustrations/simple-illustration_ import OpenSafe from '@assets/images/simple-illustrations/simple-illustration__opensafe.svg'; import PalmTree from '@assets/images/simple-illustrations/simple-illustration__palmtree.svg'; import Pencil from '@assets/images/simple-illustrations/simple-illustration__pencil.svg'; +import PerDiem from '@assets/images/simple-illustrations/simple-illustration__perdiem.svg'; import PiggyBank from '@assets/images/simple-illustrations/simple-illustration__piggybank.svg'; import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg'; import QRCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg'; @@ -264,4 +265,5 @@ export { OtherCompanyCardDetail, StripeCompanyCardDetail, WellsFargoCompanyCardDetail, + PerDiem, }; diff --git a/src/languages/en.ts b/src/languages/en.ts index f621f51abbab..92bed5ecf1f0 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2337,6 +2337,7 @@ const translations = { displayedAs: 'Displayed as', plan: 'Plan', profile: 'Profile', + perDiem: 'Per diem', bankAccount: 'Bank account', connectBankAccount: 'Connect bank account', testTransactions: 'Test transactions', @@ -2401,6 +2402,25 @@ const translations = { } }, }, + perDiem: { + subtitle: 'Set per diem rates to control daily employee spend. ', + destination: 'Destination', + subrate: 'Subrate', + amount: 'Amount', + deleteRates: () => ({ + one: 'Delete rate', + other: 'Delete rates', + }), + deletePerDiemRate: 'Delete per diem rate', + areYouSureDelete: () => ({ + one: 'Are you sure you want to delete this rate?', + other: 'Are you sure you want to delete these rates?', + }), + emptyList: { + title: 'Per diem', + subtitle: 'Set per diem rates to control daily employee spend. Import rates from a spreadsheet to get started.', + }, + }, qbd: { exportOutOfPocketExpensesDescription: 'Set how out-of-pocket expenses export to QuickBooks Desktop.', exportOutOfPocketExpensesCheckToogle: 'Mark checks as “print later”', @@ -3291,6 +3311,10 @@ const translations = { title: 'Distance rates', subtitle: 'Add, update, and enforce rates.', }, + perDiem: { + title: 'Per diem', + subtitle: 'Set Per diem rates to control daily employee spend.', + }, expensifyCard: { title: 'Expensify Card', subtitle: 'Gain insights and control over spend.', @@ -4067,6 +4091,12 @@ const translations = { description: `Rules run in the background and keep your spend under control so you don't have to sweat the small stuff.\n\nRequire expense details like receipts and descriptions, set limits and defaults, and automate approvals and payments – all in one place.`, onlyAvailableOnPlan: 'Rules are only available on the Control plan, starting at ', }, + perDiem: { + title: 'Per diem', + description: + 'Per diem is a great way to keep your daily costs compliant and predictable whenever your employees travel. Enjoy features like custom rates, default categories, and more granular details like destinations and subrates.', + onlyAvailableOnPlan: 'Per diem are only available on the Control plan, starting at ', + }, pricing: { amount: '$9 ', perActiveMember: 'per active member per month.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2084147db6aa..eefbb16870b5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2358,6 +2358,7 @@ const translations = { rules: 'Reglas', plan: 'Plan', profile: 'Perfil', + perDiem: 'Per diem', bankAccount: 'Cuenta bancaria', displayedAs: 'Mostrado como', connectBankAccount: 'Conectar cuenta bancaria', @@ -2424,6 +2425,25 @@ const translations = { } }, }, + perDiem: { + subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados. ', + destination: 'Destino', + subrate: 'Subtasa', + amount: 'Cantidad', + deleteRates: () => ({ + one: 'Eliminar tasa', + other: 'Eliminar tasas', + }), + deletePerDiemRate: 'Eliminar tasa per diem', + areYouSureDelete: () => ({ + one: '¿Estás seguro de que quieres eliminar esta tasa?', + other: '¿Estás seguro de que quieres eliminar estas tasas?', + }), + emptyList: { + title: 'Per diem', + subtitle: 'Establece dietas per diem para controlar el gasto diario de los empleados. Importa las tarifas desde una hoja de cálculo para comenzar.', + }, + }, qbd: { exportOutOfPocketExpensesDescription: 'Establezca cómo se exportan los gastos de bolsillo a QuickBooks Desktop.', exportOutOfPocketExpensesCheckToogle: 'Marcar los cheques como “imprimir más tarde”', @@ -3332,6 +3352,10 @@ const translations = { title: 'Tasas de distancia', subtitle: 'Añade, actualiza y haz cumplir las tasas.', }, + perDiem: { + title: 'Per diem', + subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados.', + }, expensifyCard: { title: 'Tarjeta Expensify', subtitle: 'Obtén información y control sobre tus gastos.', @@ -4113,6 +4137,12 @@ const translations = { description: `Las reglas se ejecutan en segundo plano y mantienen tus gastos bajo control para que no tengas que preocuparte por los detalles pequeños.\n\nExige detalles de los gastos, como recibos y descripciones, establece límites y valores predeterminados, y automatiza las aprobaciones y los pagos, todo en un mismo lugar.`, onlyAvailableOnPlan: 'Las reglas están disponibles solo en el plan Controlar, que comienza en ', }, + perDiem: { + title: 'Per diem', + description: + 'Las dietas per diem (ej.: $100 por día para comidas) son una excelente forma de mantener los gastos diarios predecibles y ajustados a las políticas de la empresa, especialmente si tus empleados viajan por negocios. Disfruta de funciones como tasas personalizadas, categorías por defecto y detalles más específicos como destinos y subtasas.', + onlyAvailableOnPlan: 'Las dietas per diem solo están disponibles en el plan Control, a partir de ', + }, note: { upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o', learnMore: 'más información', diff --git a/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts b/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts new file mode 100644 index 000000000000..de2fa3467027 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyPerDiemRatesPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyPerDiemRatesPageParams = { + policyID: string; +}; + +export default OpenPolicyPerDiemRatesPageParams; diff --git a/src/libs/API/parameters/TogglePolicyPerDiemParams.ts b/src/libs/API/parameters/TogglePolicyPerDiemParams.ts new file mode 100644 index 000000000000..363020ec5c66 --- /dev/null +++ b/src/libs/API/parameters/TogglePolicyPerDiemParams.ts @@ -0,0 +1,7 @@ +type TogglePolicyPerDiemParams = { + policyID: string; + enabled: boolean; + customUnitID: string; +}; + +export default TogglePolicyPerDiemParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 26da6b2f6f03..fb5558fb0350 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -346,4 +346,6 @@ export type {default as UpdateInvoiceCompanyNameParams} from './UpdateInvoiceCom export type {default as UpdateInvoiceCompanyWebsiteParams} from './UpdateInvoiceCompanyWebsiteParams'; export type {default as UpdateQuickbooksDesktopExpensesExportDestinationTypeParams} from './UpdateQuickbooksDesktopExpensesExportDestinationTypeParams'; export type {default as UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams} from './UpdateQuickbooksDesktopCompanyCardExpenseAccountTypeParams'; +export type {default as TogglePolicyPerDiemParams} from './TogglePolicyPerDiemParams'; +export type {default as OpenPolicyPerDiemRatesPageParams} from './OpenPolicyPerDiemRatesPageParams'; export type {default as TogglePlatformMuteParams} from './TogglePlatformMuteParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bf3f749f5bac..b8b4bb749701 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -221,6 +221,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_WORKFLOWS: 'EnablePolicyWorkflows', ENABLE_POLICY_REPORT_FIELDS: 'EnablePolicyReportFields', ENABLE_POLICY_EXPENSIFY_CARDS: 'EnablePolicyExpensifyCards', + TOGGLE_POLICY_PER_DIEM: 'TogglePolicyPerDiem', ENABLE_POLICY_COMPANY_CARDS: 'EnablePolicyCompanyCards', ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', @@ -661,6 +662,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_WORKFLOWS]: Parameters.EnablePolicyWorkflowsParams; [WRITE_COMMANDS.ENABLE_POLICY_REPORT_FIELDS]: Parameters.EnablePolicyReportFieldsParams; [WRITE_COMMANDS.ENABLE_POLICY_EXPENSIFY_CARDS]: Parameters.EnablePolicyExpensifyCardsParams; + [WRITE_COMMANDS.TOGGLE_POLICY_PER_DIEM]: Parameters.TogglePolicyPerDiemParams; [WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS]: Parameters.EnablePolicyCompanyCardsParams; [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; @@ -936,6 +938,7 @@ const READ_COMMANDS = { OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', OPEN_POLICY_WORKFLOWS_PAGE: 'OpenPolicyWorkflowsPage', OPEN_POLICY_DISTANCE_RATES_PAGE: 'OpenPolicyDistanceRatesPage', + OPEN_POLICY_PER_DIEM_RATES_PAGE: 'OpenPolicyPerDiemRatesPage', OPEN_POLICY_MORE_FEATURES_PAGE: 'OpenPolicyMoreFeaturesPage', OPEN_POLICY_ACCOUNTING_PAGE: 'OpenPolicyAccountingPage', OPEN_POLICY_PROFILE_PAGE: 'OpenPolicyProfilePage', @@ -993,6 +996,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST]: Parameters.OpenDraftWorkspaceRequestParams; [READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE]: Parameters.OpenPolicyWorkflowsPageParams; [READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE]: Parameters.OpenPolicyDistanceRatesPageParams; + [READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE]: Parameters.OpenPolicyPerDiemRatesPageParams; [READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams; [READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams; [READ_COMMANDS.OPEN_POLICY_EXPENSIFY_CARDS_PAGE]: Parameters.OpenPolicyExpensifyCardsPageParams; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 7087605e24c5..86e9c23af97b 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -50,6 +50,10 @@ function getMileageRates(policy: OnyxInputOrEntry, includeDisabledRates return; } + if (!distanceUnit.attributes) { + return; + } + mileageRates[rateID] = { rate: rate.rate, currency: rate.currency, @@ -79,7 +83,7 @@ function getDefaultMileageRate(policy: OnyxInputOrEntry): MileageRate | } const distanceUnit = PolicyUtils.getDistanceRateCustomUnit(policy); - if (!distanceUnit?.rates) { + if (!distanceUnit?.rates || !distanceUnit.attributes) { return; } const mileageRates = Object.values(getMileageRates(policy)); diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx index 2f513fe804bb..4aac30587725 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx @@ -29,6 +29,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.REPORT_FIELDS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldsPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsPage').default, + [SCREENS.WORKSPACE.PER_DIEM]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPage').default, } satisfies Screens; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 330d5f113503..7a5b31489764 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1418,6 +1418,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.COMPANY_CARDS]: { path: ROUTES.WORKSPACE_COMPANY_CARDS.route, }, + [SCREENS.WORKSPACE.PER_DIEM]: { + path: ROUTES.WORKSPACE_PER_DIEM.route, + }, [SCREENS.WORKSPACE.WORKFLOWS]: { path: ROUTES.WORKSPACE_WORKFLOWS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3eae46ac2855..ba859efff944 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1394,6 +1394,9 @@ type FullScreenNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS_ADD_NEW]: { policyID: string; }; + [SCREENS.WORKSPACE.PER_DIEM]: { + policyID: string; + }; [SCREENS.WORKSPACE.WORKFLOWS]: { policyID: string; }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 9050d3601046..b7795de936a4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -136,6 +136,13 @@ function getDistanceRateCustomUnit(policy: OnyxEntry): CustomUnit | unde return Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); } +/** + * Retrieves the per diem custom unit object for the given policy + */ +function getPerDiemCustomUnit(policy: OnyxEntry): CustomUnit | undefined { + return Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); +} + /** * Retrieves custom unit rate object from the given customUnitRateID */ @@ -1153,6 +1160,7 @@ export { getSageIntacctCreditCards, getSageIntacctBankAccounts, getDistanceRateCustomUnit, + getPerDiemCustomUnit, getDistanceRateCustomUnitRate, sortWorkspacesBySelected, removePendingFieldsFromCustomUnit, diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts new file mode 100644 index 000000000000..2ce31fd4c921 --- /dev/null +++ b/src/libs/actions/Policy/PerDiem.ts @@ -0,0 +1,122 @@ +import type {NullishDeep, OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import * as NumberUtils from '@libs/NumberUtils'; +import {navigateWhenEnableFeature} from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; +import type {OnyxData} from '@src/types/onyx/Request'; + +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = ReportUtils.getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + +/** + * Returns a client generated 13 character hexadecimal value for a custom unit ID + */ +function generateCustomUnitID(): string { + return NumberUtils.generateHexadecimalValue(13); +} + +function enablePerDiem(policyID: string, enabled: boolean, customUnitID?: string) { + const doesCustomUnitExists = !!customUnitID; + const finalCustomUnitID = doesCustomUnitExists ? customUnitID : generateCustomUnitID(); + const optimisticCustomUnit = { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: finalCustomUnitID, + enabled: true, + defaultCategory: '', + rates: {}, + }; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + arePerDiemRatesEnabled: enabled, + pendingFields: { + arePerDiemRatesEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + ...(doesCustomUnitExists ? {} : {customUnits: {[finalCustomUnitID]: optimisticCustomUnit}}), + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + arePerDiemRatesEnabled: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + arePerDiemRatesEnabled: !enabled, + pendingFields: { + arePerDiemRatesEnabled: null, + }, + }, + }, + ], + }; + + const parameters = {policyID, enabled, customUnitID: finalCustomUnitID}; + + API.write(WRITE_COMMANDS.TOGGLE_POLICY_PER_DIEM, parameters, onyxData); + + if (enabled && getIsNarrowLayout()) { + navigateWhenEnableFeature(policyID); + } +} + +function openPolicyPerDiemPage(policyID?: string) { + if (!policyID) { + return; + } + + const params = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE, params); +} + +export {enablePerDiem, openPolicyPerDiemPage}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 1dd6178d3159..d87f0321bab0 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1508,7 +1508,7 @@ function generateCustomUnitID(): string { return NumberUtils.generateHexadecimalValue(13); } -function buildOptimisticCustomUnits(reportCurrency?: string): OptimisticCustomUnits { +function buildOptimisticDistanceRateCustomUnits(reportCurrency?: string): OptimisticCustomUnits { const currency = reportCurrency ?? allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD; const customUnitID = generateCustomUnitID(); const customUnitRateID = generateCustomUnitID(); @@ -1550,7 +1550,7 @@ function buildOptimisticCustomUnits(reportCurrency?: string): OptimisticCustomUn */ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const optimisticData: OnyxUpdate[] = [ { @@ -1605,7 +1605,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string, engagementChoice?: string) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const { adminsChatReportID, @@ -1852,7 +1852,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): CreateWorkspaceParams { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticCustomUnits(); + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); const {expenseChatData, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID} = ReportUtils.buildOptimisticWorkspaceChats( policyID, @@ -2144,7 +2144,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF const workspaceName = generateDefaultWorkspaceName(sessionEmail); const employeeAccountID = iouReport.ownerAccountID; const employeeEmail = iouReport.ownerEmail ?? ''; - const {customUnits, customUnitID, customUnitRateID} = buildOptimisticCustomUnits(iouReport.currency); + const {customUnits, customUnitID, customUnitRateID} = buildOptimisticDistanceRateCustomUnits(iouReport.currency); const oldPersonalPolicyID = iouReport.policyID; const iouReportID = iouReport.reportID; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index f9d1fedb91c1..971cc064f9a5 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -62,7 +62,8 @@ type WorkspaceMenuItem = { | typeof SCREENS.WORKSPACE.EXPENSIFY_CARD | typeof SCREENS.WORKSPACE.COMPANY_CARDS | typeof SCREENS.WORKSPACE.REPORT_FIELDS - | typeof SCREENS.WORKSPACE.RULES; + | typeof SCREENS.WORKSPACE.RULES + | typeof SCREENS.WORKSPACE.PER_DIEM; badgeText?: string; }; @@ -110,6 +111,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac [CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED]: policy?.areReportFieldsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED]: policy?.areRulesEnabled, [CONST.POLICY.MORE_FEATURES.ARE_INVOICES_ENABLED]: policy?.areInvoicesEnabled, + [CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED]: policy?.arePerDiemRatesEnabled, }), [policy], ) as PolicyFeatureStates; @@ -224,6 +226,15 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }); } + if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED]) { + protectedCollectPolicyMenuItems.push({ + translationKey: 'workspace.common.perDiem', + icon: Expensicons.CalendarSolid, + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM.getRoute(policyID)))), + routeName: SCREENS.WORKSPACE.PER_DIEM, + }); + } + if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED]) { protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.workflows', diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 11acbda1ebbe..4eb1f752d176 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -12,15 +12,17 @@ import Section from '@components/Section'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; -import {isControlPolicy} from '@libs/PolicyUtils'; +import {getPerDiemCustomUnit, isControlPolicy} from '@libs/PolicyUtils'; import * as Category from '@userActions/Policy/Category'; import * as DistanceRate from '@userActions/Policy/DistanceRate'; +import * as PerDiem from '@userActions/Policy/PerDiem'; import * as Policy from '@userActions/Policy/Policy'; import * as Tag from '@userActions/Policy/Tag'; import * as Report from '@userActions/Report'; @@ -62,6 +64,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); + const {canUsePerDiem} = usePermissions(); const hasAccountingConnection = !isEmptyObject(policy?.connections); const isAccountingEnabled = !!policy?.areConnectionsEnabled || !isEmptyObject(policy?.connections); const isSyncTaxEnabled = @@ -78,6 +81,8 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const [isDisableExpensifyCardWarningModalOpen, setIsDisableExpensifyCardWarningModalOpen] = useState(false); const [isDisableCompanyCardsWarningModalOpen, setIsDisableCompanyCardsWarningModalOpen] = useState(false); + const perDiemCustomUnit = getPerDiemCustomUnit(policy); + const onDisabledOrganizeSwitchPress = useCallback(() => { if (!hasAccountingConnection) { return; @@ -140,6 +145,26 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro }, }); + if (canUsePerDiem) { + spendItems.push({ + icon: Illustrations.PerDiem, + titleTranslationKey: 'workspace.moreFeatures.perDiem.title', + subtitleTranslationKey: 'workspace.moreFeatures.perDiem.subtitle', + isActive: policy?.arePerDiemRatesEnabled ?? false, + pendingAction: policy?.pendingFields?.arePerDiemRatesEnabled, + action: (isEnabled: boolean) => { + if (!policyID) { + return; + } + if (isEnabled && !isControlPolicy(policy)) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.perDiem.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID))); + return; + } + PerDiem.enablePerDiem(policyID, isEnabled, perDiemCustomUnit?.customUnitID); + }, + }); + } + const manageItems: Item[] = [ { icon: Illustrations.Workflows, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index f9918ff6612d..eed24a4ea13f 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -76,7 +76,7 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag }; const onToggleTrackTax = (isOn: boolean) => { - if (!customUnit) { + if (!customUnit || !customUnit.attributes) { return; } const attributes = {...customUnit?.attributes, taxEnabled: isOn}; diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx new file mode 100644 index 000000000000..9430cfd911b5 --- /dev/null +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -0,0 +1,452 @@ +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; +import lodashSortBy from 'lodash/sortBy'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {ActivityIndicator, View} from 'react-native'; +import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import ConfirmModal from '@components/ConfirmModal'; +import EmptyStateComponent from '@components/EmptyStateComponent'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import * as Illustrations from '@components/Icon/Illustrations'; +import LottieAnimations from '@components/LottieAnimations'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TableListItem from '@components/SelectionList/TableListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionListWithModal from '@components/SelectionListWithModal'; +import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useNetwork from '@hooks/useNetwork'; +import usePolicy from '@hooks/usePolicy'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import localeCompare from '@libs/LocaleCompare'; +import Navigation from '@libs/Navigation/Navigation'; +import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Link from '@userActions/Link'; +import * as Modal from '@userActions/Modal'; +import * as PerDiem from '@userActions/Policy/PerDiem'; +import CONST from '@src/CONST'; +// import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {Rate} from '@src/types/onyx/Policy'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; + +type PolicyOption = ListItem & { + /** subRateID is used as a key for identification of the entry */ + subRateID: string; + + /** rateID is used as a key for identification of the entry */ + rateID: string; +}; + +type SubRateData = { + pendingAction?: PendingAction; + destination: string; + subRateName: string; + rate: number; + currency: string; + rateID: string; + subRateID: string; +}; + +function getSubRatesData(customUnitRates: Rate[]) { + const subRatesData: SubRateData[] = []; + for (const rate of customUnitRates) { + const subRates = rate.subRates; + if (subRates) { + for (const subRate of subRates) { + subRatesData.push({ + pendingAction: rate.pendingAction, + destination: rate.name ?? '', + subRateName: subRate.name, + rate: subRate.rate, + currency: rate.currency ?? CONST.CURRENCY.USD, + rateID: rate.customUnitRateID ?? '', + subRateID: subRate.id, + }); + } + } + } + return subRatesData; +} + +function generateSingleSubRateData(customUnitRates: Rate[], rateID: string, subRateID: string) { + const selectedRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); + if (!selectedRate) { + return null; + } + const selectedSubRate = selectedRate.subRates?.find((subRate) => subRate.id === subRateID); + if (!selectedSubRate) { + return null; + } + return { + pendingAction: selectedRate.pendingAction, + destination: selectedRate.name ?? '', + subRateName: selectedSubRate.name, + rate: selectedSubRate.rate, + currency: selectedRate.currency ?? CONST.CURRENCY.USD, + rateID: selectedRate.customUnitRateID ?? '', + subRateID: selectedSubRate.id, + }; +} + +type WorkspacePerDiemPageProps = StackScreenProps; + +function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {windowWidth} = useWindowDimensions(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); + const [selectedPerDiem, setSelectedPerDiem] = useState([]); + const [deletePerDiemConfirmModalVisible, setDeletePerDiemConfirmModalVisible] = useState(false); + const isFocused = useIsFocused(); + const policyID = route.params.policyID ?? '-1'; + const backTo = route.params?.backTo; + const policy = usePolicy(policyID); + const {selectionMode} = useMobileSelectionMode(); + + const customUnit = getPerDiemCustomUnit(policy); + const customUnitRates: Record = useMemo(() => customUnit?.rates ?? {}, [customUnit]); + + const allRatesArray = Object.values(customUnitRates); + + const allSubRates = getSubRatesData(allRatesArray); + + // Filter out rates that will be deleted + const allSelectableSubRates = useMemo(() => allSubRates.filter((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [allSubRates]); + + const canSelectMultiple = shouldUseNarrowLayout ? selectionMode?.isEnabled : true; + + const fetchPerDiem = useCallback(() => { + PerDiem.openPolicyPerDiemPage(policyID); + }, [policyID]); + + const {isOffline} = useNetwork({onReconnect: fetchPerDiem}); + + useFocusEffect( + useCallback(() => { + fetchPerDiem(); + }, [fetchPerDiem]), + ); + + useEffect(() => { + if (isFocused) { + return; + } + setSelectedPerDiem([]); + }, [isFocused]); + + const subRatesList = useMemo( + () => + (lodashSortBy(allSubRates, 'destination', localeCompare) as SubRateData[]).map((value) => { + const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + return { + text: value.destination, + subRateID: value.subRateID, + rateID: value.rateID, + keyForList: value.subRateID, + isSelected: selectedPerDiem.find((rate) => rate.subRateID === value.subRateID) !== undefined && canSelectMultiple, + isDisabled, + pendingAction: value.pendingAction, + rightElement: ( + <> + + {value.subRateName} + + + + {CurrencyUtils.convertAmountToDisplayString(value.rate, value.currency)} + + + + ), + }; + }), + [allSubRates, selectedPerDiem, canSelectMultiple, styles.flex1, styles.alignItemsStart, styles.textSupporting, styles.label, styles.alignSelfEnd, styles.pl2], + ); + + const toggleSubRate = (subRate: PolicyOption) => { + if (selectedPerDiem.find((selectedSubRate) => selectedSubRate.subRateID === subRate.subRateID) !== undefined) { + setSelectedPerDiem((prev) => prev.filter((selectedSubRate) => selectedSubRate.subRateID !== subRate.subRateID)); + } else { + const subRateData = generateSingleSubRateData(allRatesArray, subRate.rateID, subRate.subRateID); + if (!subRateData) { + return; + } + setSelectedPerDiem((prev) => [...prev, subRateData]); + } + }; + + const toggleAllSubRates = () => { + if (selectedPerDiem.length === allSelectableSubRates.length) { + setSelectedPerDiem([]); + } else { + setSelectedPerDiem([...allSelectableSubRates]); + } + }; + + const getCustomListHeader = () => ( + + + {translate('workspace.perDiem.destination')} + + + {translate('workspace.perDiem.subrate')} + + + {translate('workspace.perDiem.amount')} + + + ); + + const openSettings = () => { + // TODO: Uncomment this when the import feature is ready + // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_RATES_SETTINGS.getRoute(policyID)); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const openSubRateDetails = (rate: PolicyOption) => { + // TODO: Uncomment this when the import feature is ready + // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_RATE_DETAILS.getRoute(policyID, rate.rateID, rate.subRateID)); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dismissError = (item: PolicyOption) => { + // TODO: Implement this when the editing feature is ready + }; + + const handleDeletePerDiemRates = () => { + setSelectedPerDiem([]); + setDeletePerDiemConfirmModalVisible(false); + }; + + const getHeaderButtons = () => { + const options: Array>> = []; + + if (shouldUseNarrowLayout ? canSelectMultiple : selectedPerDiem.length > 0) { + options.push({ + icon: Expensicons.Trashcan, + text: translate('workspace.perDiem.deleteRates', {count: selectedPerDiem.length}), + value: CONST.POLICY.BULK_ACTION_TYPES.DELETE, + onSelected: () => setDeletePerDiemConfirmModalVisible(true), + }); + + return ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {count: selectedPerDiem.length})} + options={options} + isSplitButton={false} + style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} + isDisabled={!selectedPerDiem.length} + /> + ); + } + + return ( + +