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,