diff --git a/assets/images/product-illustrations/gps-track--orange.svg b/assets/images/product-illustrations/gps-track--orange.svg new file mode 100644 index 000000000000..400958af31ca --- /dev/null +++ b/assets/images/product-illustrations/gps-track--orange.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/src/components/Icon/Illustrations.js b/src/components/Icon/Illustrations.js index 4b209de1924b..9da4bcae764f 100644 --- a/src/components/Icon/Illustrations.js +++ b/src/components/Icon/Illustrations.js @@ -14,6 +14,7 @@ import MoneyMousePink from '../../../assets/images/product-illustrations/money-m import ReceiptYellow from '../../../assets/images/product-illustrations/receipt--yellow.svg'; import RocketOrange from '../../../assets/images/product-illustrations/rocket--orange.svg'; import TadaYellow from '../../../assets/images/product-illustrations/tada--yellow.svg'; +import GpsTrackOrange from '../../../assets/images/product-illustrations/gps-track--orange.svg'; export { BankArrowPink, @@ -32,4 +33,5 @@ export { ReceiptYellow, RocketOrange, TadaYellow, + GpsTrackOrange, }; diff --git a/src/languages/en.js b/src/languages/en.js index 0ff4c66ac796..4ca7afc01ead 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -718,7 +718,7 @@ export default { edit: 'Edit workspace', delete: 'Delete Workspace', settings: 'General settings', - reimburse: 'Reimburse receipts', + reimburse: 'Reimburse expenses', bills: 'Pay bills', invoices: 'Send invoices', travel: 'Book travel', @@ -762,8 +762,14 @@ export default { reimburse: { captureReceipts: 'Capture receipts', fastReimbursementsHappyMembers: 'Fast reimbursements = happy members!', + kilometers: 'Kilometers', + miles: 'Miles', viewAllReceipts: 'View all receipts', reimburseReceipts: 'Reimburse receipts', + trackDistance: 'Track distance', + trackDistanceCopy: 'Set the per mile/km rate and choose a default unit to track.', + trackDistanceRate: 'Rate', + trackDistanceUnit: 'Unit', unlockNextDayReimbursements: 'Unlock next-day reimbursements', captureNoVBACopyBeforeEmail: 'Ask your workspace members to forward receipts to ', captureNoVBACopyAfterEmail: ' and download the Expensify App to track cash expenses on the go.', diff --git a/src/languages/es.js b/src/languages/es.js index a6da1b760689..0512bea6d341 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -764,8 +764,14 @@ export default { reimburse: { captureReceipts: 'Captura recibos', fastReimbursementsHappyMembers: '¡Reembolsos rápidos = miembros felices!', + kilometers: '', + miles: '', viewAllReceipts: 'Ver todos los recibos', reimburseReceipts: 'Reembolsar recibos', + trackDistance: '', + trackDistanceCopy: '', + trackDistanceRate: '', + trackDistanceUnit: '', unlockNextDayReimbursements: 'Desbloquea reembolsos diarios', captureNoVBACopyBeforeEmail: 'Pide a los miembros de tu espacio de trabajo que envíen recibos a ', captureNoVBACopyAfterEmail: ' y descarga la App de Expensify para controlar tus gastos en efectivo sobre la marcha.', diff --git a/src/libs/API.js b/src/libs/API.js index 27a19f720ed1..2158a0505fd7 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -1091,6 +1091,31 @@ function Policy_Create(parameters) { return Network.post(commandName, parameters); } +/** + * @param {Object} parameters + * @param {String} parameters.policyID + * @param {String} parameters.value + * @returns {Promise} + */ +function Policy_CustomUnit_Update(parameters) { + const commandName = 'Policy_CustomUnit_Update'; + requireParameters(['policyID', 'customUnit'], parameters, commandName); + return Network.post(commandName, parameters); +} + +/** + * @param {Object} parameters + * @param {String} parameters.policyID + * @param {String} parameters.customUnitID + * @param {String} parameters.value + * @returns {Promise} + */ +function Policy_CustomUnitRate_Update(parameters) { + const commandName = 'Policy_CustomUnitRate_Update'; + requireParameters(['policyID', 'customUnitID', 'customUnitRate'], parameters, commandName); + return Network.post(commandName, parameters); +} + /** * @param {Object} parameters * @param {String} [parameters.policyID] @@ -1267,6 +1292,8 @@ export { GetLocalCurrency, GetCurrencyList, Policy_Create, + Policy_CustomUnit_Update, + Policy_CustomUnitRate_Update, Policy_Employees_Remove, PreferredLocale_Update, Policy_Delete, diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 5dd0dd627051..6a7e7cd49150 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -53,10 +53,27 @@ function getSimplifiedEmployeeList(employeeList) { * @param {String} [fullPolicyOrPolicySummary.avatarURL] * @param {String} [fullPolicyOrPolicySummary.value.avatarURL] * @param {Object} [fullPolicyOrPolicySummary.value.employeeList] + * @param {Object} [fullPolicyOrPolicySummary.value.customUnits] + * @param {Boolean} isFromFullPolicy, * @returns {Object} */ -function getSimplifiedPolicyObject(fullPolicyOrPolicySummary) { +function getSimplifiedPolicyObject(fullPolicyOrPolicySummary, isFromFullPolicy) { + const customUnit = lodashGet(fullPolicyOrPolicySummary, 'value.customUnits[0]', undefined); + const customUnitValue = lodashGet(customUnit, 'attributes.unit', 'mi'); + const customUnitRate = lodashGet(customUnit, 'rates[0]', {}); + const customUnitSimplified = customUnit && { + id: customUnit.customUnitID, + name: customUnit.name, + value: customUnitValue, + rate: { + id: customUnitRate.customUnitRateID, + name: customUnitRate.name, + currency: customUnitRate.currency, + value: Number(customUnitRate.rate), + }, + }; return { + isFromFullPolicy, id: fullPolicyOrPolicySummary.id, name: fullPolicyOrPolicySummary.name, role: fullPolicyOrPolicySummary.role, @@ -68,6 +85,7 @@ function getSimplifiedPolicyObject(fullPolicyOrPolicySummary) { // avatarUrl will be nested within the key "value" avatarURL: fullPolicyOrPolicySummary.avatarURL || lodashGet(fullPolicyOrPolicySummary, 'value.avatarURL', ''), employeeList: getSimplifiedEmployeeList(lodashGet(fullPolicyOrPolicySummary, 'value.employeeList')), + customUnit: customUnitSimplified, }; } @@ -192,7 +210,7 @@ function getPolicyList() { const policyCollection = _.reduce(data.policySummaryList, (memo, policy) => ({ ...memo, - [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: getSimplifiedPolicyObject(policy), + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: getSimplifiedPolicyObject(policy, false), }), {}); if (!_.isEmpty(policyCollection)) { @@ -229,7 +247,7 @@ function loadFullPolicy(policyID) { return; } - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, getSimplifiedPolicyObject(policy)); + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, getSimplifiedPolicyObject(policy, true)); }); } @@ -409,6 +427,66 @@ function hideWorkspaceAlertMessage(policyID) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {alertMessage: ''}); } +/** + * @param {String} policyID + * @param {Object} values + */ +function setCustomUnit(policyID, values) { + API.Policy_CustomUnit_Update({ + policyID: policyID.toString(), + customUnit: JSON.stringify(values), + lastModified: null, + }) + .then((response) => { + if (response.jsonCode !== 200) { + throw new Error(); + } + + updateLocalPolicyValues(policyID, { + customUnit: { + id: values.customUnitID, + name: values.name, + value: values.attributes.unit, + }, + }); + }).catch(() => { + // Show the user feedback + Growl.error(Localize.translateLocal('workspace.editor.genericFailureMessage'), 5000); + }); +} + +/** + * @param {String} policyID + * @param {String} customUnitID + * @param {Object} values + */ +function setCustomUnitRate(policyID, customUnitID, values) { + API.Policy_CustomUnitRate_Update({ + policyID: policyID.toString(), + customUnitID: customUnitID.toString(), + customUnitRate: JSON.stringify(values), + lastModified: null, + }) + .then((response) => { + if (response.jsonCode !== 200) { + throw new Error(); + } + + updateLocalPolicyValues(policyID, { + customUnit: { + rate: { + id: values.customUnitRateID, + name: values.name, + value: Number(values.rate), + }, + }, + }); + }).catch(() => { + // Show the user feedback + Growl.error(Localize.translateLocal('workspace.editor.genericFailureMessage'), 5000); + }); +} + /** * Stores in Onyx the policy ID of the last workspace that was accessed by the user * @param {String} policyID @@ -431,5 +509,7 @@ export { deletePolicy, createAndNavigate, createAndGetPolicyList, + setCustomUnit, + setCustomUnitRate, updateLastAccessedWorkspace, }; diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js b/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js index e59834f976b7..6812fdaad943 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseNoVBAView.js @@ -1,6 +1,11 @@ import React from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import _ from 'underscore'; +import TextInput from '../../../components/TextInput'; +import Picker from '../../../components/Picker'; import Text from '../../../components/Text'; import styles from '../../../styles/styles'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; @@ -11,61 +16,189 @@ import Navigation from '../../../libs/Navigation/Navigation'; import ROUTES from '../../../ROUTES'; import CopyTextToClipboard from '../../../components/CopyTextToClipboard'; import * as Link from '../../../libs/actions/Link'; +import compose from '../../../libs/compose'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as Policy from '../../../libs/actions/Policy'; +import withFullPolicy from '../withFullPolicy'; const propTypes = { /** The policy ID currently being configured */ policyID: PropTypes.string.isRequired, + /** Policy values needed in the component */ + policy: PropTypes.shape({ + customUnit: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + value: PropTypes.string, + rate: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + value: PropTypes.number, + currency: PropTypes.string, + }), + }), + }).isRequired, + ...withLocalizePropTypes, }; -const WorkspaceReimburseNoVBAView = props => ( - <> -
Link.openOldDotLink(`expenses?policyIDList=${props.policyID}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`), - icon: Expensicons.Receipt, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - }, - ]} - > - - - {props.translate('workspace.reimburse.captureNoVBACopyBeforeEmail')} - - {props.translate('workspace.reimburse.captureNoVBACopyAfterEmail')} - - -
- -
Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(props.policyID)), - icon: Expensicons.Bank, - shouldShowRightIcon: true, - }, - ]} - > - - {props.translate('workspace.reimburse.unlockNoVBACopy')} - -
- -); + +class WorkspaceReimburseNoVBAView extends React.Component { + unitItems = [ + { + label: this.props.translate('workspace.reimburse.kilometers'), + value: 'km', + }, + { + label: this.props.translate('workspace.reimburse.miles'), + value: 'mi', + }, + ]; + + /** + * Set the rate throttled by 3 seconds so the user does not have to type over the corrected value + */ + updateRateValueThrottled = _.throttle((value) => { + this.setState({rateValue: this.getRateDisplayValue(value)}); + Policy.setCustomUnitRate(this.props.policyID, this.state.unitID, { + customUnitRateID: this.state.rateID, + name: this.state.rateName, + rate: value, + }, null); + }, 3000, {leading: false, trailing: true}); + + constructor(props) { + super(props); + this.state = { + unitID: lodashGet(props, 'policy.customUnit.id', ''), + unitName: lodashGet(props, 'policy.customUnit.name', ''), + unitValue: lodashGet(props, 'policy.customUnit.value', 'mi'), + rateID: lodashGet(props, 'policy.customUnit.rate.id', ''), + rateName: lodashGet(props, 'policy.customUnit.rate.name', ''), + rateValue: this.getRateDisplayValue(lodashGet(props, 'policy.customUnit.rate.value', '')), + rateCurrency: lodashGet(props, 'policy.customUnit.rate.currency', ''), + }; + } + + getRateDisplayValue(value) { + const numValue = parseFloat(value); + return !Number.isNaN(numValue) + ? numValue.toFixed(2).toString() + : ''; + } + + setRate(value) { + const numValue = parseFloat(value); + if (Number.isNaN(numValue)) { + this.setState({rateValue: ''}); + return; + } + + // Set the immediate value so the user does not lose the input + this.setState({rateValue: numValue.toString()}); + + // Set the corrected value with a delay and sync to the server + this.updateRateValueThrottled(numValue); + } + + setUnit(value) { + this.setState({unitValue: value}); + + Policy.setCustomUnit(this.props.policyID, { + customUnitID: this.state.unitID, + customUnitName: this.state.unitName, + attributes: {unit: value}, + }, null); + } + + render() { + return ( + <> +
Link.openOldDotLink(`expenses?policyIDList=${this.props.policyID}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`), + icon: Expensicons.Receipt, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + }, + ]} + > + + + {this.props.translate('workspace.reimburse.captureNoVBACopyBeforeEmail')} + + {this.props.translate('workspace.reimburse.captureNoVBACopyAfterEmail')} + + +
+ +
+ + {this.props.translate('workspace.reimburse.trackDistanceCopy')} + + + + this.setRate(value)} + value={this.state.rateValue} + autoCompleteType="off" + autoCorrect={false} + /> + + + this.setUnit(value)} + /> + + +
+ +
Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(this.props.policyID)), + icon: Expensicons.Bank, + shouldShowRightIcon: true, + }, + ]} + > + + {this.props.translate('workspace.reimburse.unlockNoVBACopy')} + +
+ + ); + } +} WorkspaceReimburseNoVBAView.propTypes = propTypes; WorkspaceReimburseNoVBAView.displayName = 'WorkspaceReimburseNoVBAView'; -export default withLocalize(WorkspaceReimburseNoVBAView); +export default compose( + withFullPolicy, + withLocalize, + withOnyx({ + policy: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + }, + }), +)(WorkspaceReimburseNoVBAView); diff --git a/src/pages/workspace/withFullPolicy.js b/src/pages/workspace/withFullPolicy.js index e5ad8f16cd51..96f191245700 100644 --- a/src/pages/workspace/withFullPolicy.js +++ b/src/pages/workspace/withFullPolicy.js @@ -88,8 +88,9 @@ export default function (WrappedComponent) { const WithFullPolicy = (props) => { const currentRoute = _.last(useNavigationState(state => state.routes || [])); const policyID = getPolicyIDFromRoute(currentRoute); + const isFromFullPolicy = lodashGet(props, 'policy.isFromFullPolicy', false) || lodashGet(props, `policy.policy_${policyID}.isFromFullPolicy`, false); - if (_.isString(policyID) && !_.isEmpty(policyID) && !isPreviousRouteInSameWorkspace(currentRoute.name, policyID)) { + if (_.isString(policyID) && !_.isEmpty(policyID) && (!isFromFullPolicy || !isPreviousRouteInSameWorkspace(currentRoute.name, policyID))) { Policy.loadFullPolicy(policyID); Policy.updateLastAccessedWorkspace(policyID); } diff --git a/src/styles/styles.js b/src/styles/styles.js index f533e58ff84a..9d3207277e8d 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -141,6 +141,19 @@ const styles = { ...wordBreak, ...whiteSpace, + rateCol: { + margin: 0, + padding: 0, + flexBasis: '48%', + }, + + unitCol: { + margin: 0, + padding: 0, + marginLeft: '4%', + flexBasis: '48%', + }, + webViewStyles, link,