Skip to content

Commit

Permalink
Merge branch 'main' into feat/38971-tracking-expense
Browse files Browse the repository at this point in the history
# Conflicts:
#	src/libs/ReportUtils.ts
  • Loading branch information
paultsimura committed Apr 8, 2024
2 parents 0a79c0d + ce06355 commit cc3c52d
Show file tree
Hide file tree
Showing 31 changed files with 669 additions and 407 deletions.
11 changes: 10 additions & 1 deletion assets/images/avatars/fallback-avatar.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 63 additions & 0 deletions docs/articles/expensify-classic/workspaces/Currency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: Report Currency
description: Understanding expense and report currency
---

# Overview
As a workspace admin, you can choose a default currency for your employees' expense reports, and we’ll automatically convert any expenses into that currency.

Here are a few essential things to remember:

- Currency settings for a workspace apply to all expenses under that workspace. If you need different default currencies for certain employees, creating separate workspaces and configuring the currency settings is best.
- As an admin, the currency settings you establish in the workspace will take precedence over any currency settings individual users may have in their accounts.
- Currency is a workspace-level setting, meaning the currency you set will determine the currency for all expenses submitted on that workspace.

# How to select the currency on a workspace

## As an admin on a group workspace

1. Sign into your Expensify web account
2. Go to **Settings > Workspaces > Group > _[Workspace Name]_> Reports > Report Basics**
3. Adjust the **Report Output Currency**

## On an individual workspace

1. Sign into your Expensify web account
2. Go to **Settings > Workspaces > Individual >_[Workspace Name]_> Reports > Report Basics**
3. Adjust the **Report Output Currency**

Please note the currency setting on an individual workspace is overridden when you submit a report on a group workspace.

# Deep Dive

## Conversion Rates

Using data from Open Exchange Rates, Expensify takes the average rate on the day the expense occurred to convert an expense from one currency to another. The conversion rate can vary depending on when the expense happened since the rate is determined after the market closes on that specific date.

If the markets aren’t open on the day the expense takes place (i.e., on a Saturday), Expensify will use the daily average rate from the last available market day before the purchase took place.

When an expense is logged for a future date, possibly to anticipate a purchase that has yet to occur, we'll use the most recent available data. This means the report's value may change up to the day of that expense.

## Managing expenses for employees in several different countries

Suppose you have employees scattered across the globe who submit expense reports in various currencies. The best way to manage those expenses is to create separate group workspaces for each location or region where your employees are based.

Then, set the default currency for that workspace to match the currency in which the employees are reimbursed.

For example, if you have employees in the US, France, Japan, and India, you’d want to create four separate workspaces, add the employees to each, and then set the corresponding currency for each workspace.

{% include faq-begin.md %}

## I have expenses in several different currencies. How will this show up on a report?

If you're traveling to foreign countries during a reporting period and making purchases in various currencies, each expense is imported with the currency of the purchase.

On your expense report, Expensify will automatically convert each expense to the default currency set for the group workspace.

## How does the currency of an expense impact the conversion rate?

Expenses entered in a foreign currency are automatically converted to the default currency on your workspace. The conversion uses the day’s average trading rate pulled from [Open Exchange Rates](https://openexchangerates.org/).

If you want to bypass the exchange rate conversion, you can manually enter an expense in your default currency instead.

{% include faq-end.md %}
28 changes: 14 additions & 14 deletions src/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,26 @@ function Avatar({
setImageError(false);
}, [source]);

if (!source) {
return null;
}

const isWorkspace = type === CONST.ICON_TYPE_WORKSPACE;
const iconSize = StyleUtils.getAvatarSize(size);

const imageStyle: StyleProp<ImageStyle> = [StyleUtils.getAvatarStyle(size), imageStyles, styles.noBorderRadius];
const iconStyle = imageStyles ? [StyleUtils.getAvatarStyle(size), styles.bgTransparent, imageStyles] : undefined;

const iconFillColor = isWorkspace ? StyleUtils.getDefaultWorkspaceAvatarColor(name).fill : fill;
// We pass the color styles down to the SVG for the workspace and fallback avatar.
const useFallBackAvatar = imageError || source === Expensicons.FallbackAvatar || !source;
const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(name) : fallbackIcon || Expensicons.FallbackAvatar;
const fallbackAvatarTestID = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatarTestID(name) : fallbackIconTestID || 'SvgFallbackAvatar Icon';

const avatarSource = imageError ? fallbackAvatar : source;
const avatarSource = useFallBackAvatar ? fallbackAvatar : source;

let iconColors;
if (isWorkspace) {
iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(name);
} else if (useFallBackAvatar) {
iconColors = StyleUtils.getBackgroundColorAndFill(theme.border, theme.icon);
} else {
iconColors = null;
}

return (
<View style={[containerStyles, styles.pointerEventsNone]}>
Expand All @@ -107,13 +112,8 @@ function Avatar({
src={avatarSource}
height={iconSize}
width={iconSize}
fill={imageError ? theme.offline : iconFillColor}
additionalStyles={[
StyleUtils.getAvatarBorderStyle(size, type),
isWorkspace && StyleUtils.getDefaultWorkspaceAvatarColor(name),
imageError && StyleUtils.getBackgroundColorStyle(theme.fallbackIconColor),
iconAdditionalStyles,
]}
fill={imageError ? iconColors?.fill ?? theme.offline : iconColors?.fill ?? fill}
additionalStyles={[StyleUtils.getAvatarBorderStyle(size, type), iconColors, iconAdditionalStyles]}
/>
</View>
)}
Expand Down
2 changes: 2 additions & 0 deletions src/components/ReportActionItem/MoneyReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground';
import variables from '@styles/variables';
import * as reportActions from '@src/libs/actions/Report';
import ROUTES from '@src/ROUTES';
import type {Policy, PolicyReportField, Report} from '@src/types/onyx';

Expand Down Expand Up @@ -81,6 +82,7 @@ function MoneyReportView({report, policy, shouldShowHorizontalRule}: MoneyReport
errors={report.errorFields?.[fieldKey]}
errorRowStyles={styles.ph5}
key={`menuItem-${fieldKey}`}
onClose={() => reportActions.clearReportFieldErrors(report.reportID, reportField)}
>
<MenuItemWithTopDescription
description={Str.UCFirst(reportField.name)}
Expand Down
9 changes: 9 additions & 0 deletions src/components/SelectionList/BaseListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ function BaseListItem<TItem extends ListItem>({
</View>
</View>
)}
{!item.isSelected && !!item.brickRoadIndicator && (
<View style={[styles.alignItemsCenter, styles.justifyContentCenter]}>
<Icon
src={Expensicons.DotIndicator}
fill={item.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO ? theme.iconSuccessFill : theme.danger}
/>
</View>
)}

{rightHandSideComponentRender()}
</View>
{FooterComponent}
Expand Down
25 changes: 19 additions & 6 deletions src/components/SelectionList/BaseSelectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ function BaseSelectionList<TItem extends ListItem>(
textInputRef,
headerMessageStyle,
shouldHideListOnInitialRender = true,
textInputIconLeft,
sectionTitleStyles,
textInputAutoFocus = true,
}: BaseSelectionListProps<TItem>,
ref: ForwardedRef<SelectionListHandle>,
) {
Expand All @@ -79,7 +82,7 @@ function BaseSelectionList<TItem extends ListItem>(
const listRef = useRef<RNSectionList<TItem, SectionWithIndexOffset<TItem>>>(null);
const innerTextInputRef = useRef<RNTextInput | null>(null);
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const shouldShowTextInput = !!textInputLabel;
const shouldShowTextInput = !!textInputLabel || !!textInputIconLeft;
const shouldShowSelectAll = !!onSelectAll;
const activeElementRole = useActiveElementRole();
const isFocused = useIsFocused();
Expand Down Expand Up @@ -310,7 +313,7 @@ function BaseSelectionList<TItem extends ListItem>(
// We do this so that we can reference the height in `getItemLayout` –
// we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item.
// So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well.
<View style={[styles.optionsListSectionHeader, styles.justifyContentCenter]}>
<View style={[styles.optionsListSectionHeader, styles.justifyContentCenter, sectionTitleStyles]}>
<Text style={[styles.ph4, styles.textLabelSupporting]}>{section.title}</Text>
</View>
);
Expand Down Expand Up @@ -377,6 +380,9 @@ function BaseSelectionList<TItem extends ListItem>(
/** Focuses the text input when the component comes into focus and after any navigation animations finish. */
useFocusEffect(
useCallback(() => {
if (!textInputAutoFocus) {
return;
}
if (shouldShowTextInput) {
focusTimeoutRef.current = setTimeout(() => {
if (!innerTextInputRef.current) {
Expand All @@ -391,7 +397,7 @@ function BaseSelectionList<TItem extends ListItem>(
}
clearTimeout(focusTimeoutRef.current);
};
}, [shouldShowTextInput]),
}, [shouldShowTextInput, textInputAutoFocus]),
);

const prevTextInputValue = usePrevious(textInputValue);
Expand Down Expand Up @@ -494,8 +500,12 @@ function BaseSelectionList<TItem extends ListItem>(
return;
}

// eslint-disable-next-line no-param-reassign
textInputRef.current = element as RNTextInput;
if (typeof textInputRef === 'function') {
textInputRef(element as RNTextInput);
} else {
// eslint-disable-next-line no-param-reassign
textInputRef.current = element as RNTextInput;
}
}}
label={textInputLabel}
accessibilityLabel={textInputLabel}
Expand All @@ -508,14 +518,17 @@ function BaseSelectionList<TItem extends ListItem>(
inputMode={inputMode}
selectTextOnFocus
spellCheck={false}
iconLeft={textInputIconLeft}
onSubmitEditing={selectFocusedOption}
blurOnSubmit={!!flattenedSections.allOptions.length}
isLoading={isLoadingNewOptions}
testID="selection-list-text-input"
/>
</View>
)}
{!!headerMessage && (
{/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */}
{/* This is misleading because we might be in the process of loading fresh options from the server. */}
{!isLoadingNewOptions && !!headerMessage && (
<View style={headerMessageStyle ?? [styles.ph5, styles.pb5]}>
<Text style={[styles.textLabel, styles.colorMuted]}>{headerMessage}</Text>
</View>
Expand Down
23 changes: 18 additions & 5 deletions src/components/SelectionList/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type {MutableRefObject, ReactElement, ReactNode} from 'react';
import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextInput, TextStyle, ViewStyle} from 'react-native';
import type {MaybePhraseKey} from '@libs/Localize';
import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
import type CONST from '@src/CONST';
import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import type {ReceiptErrors} from '@src/types/onyx/Transaction';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
import type IconAsset from '@src/types/utils/IconAsset';
import type InviteMemberListItem from './InviteMemberListItem';
import type RadioListItem from './RadioListItem';
import type TableListItem from './TableListItem';
Expand Down Expand Up @@ -33,7 +35,7 @@ type CommonListItemProps<TItem> = {
onDismissError?: (item: TItem) => void;

/** Component to display on the right side */
rightHandSideComponent?: ((item: TItem) => ReactElement<TItem>) | ReactElement | null;
rightHandSideComponent?: ((item: TItem) => ReactElement<TItem> | null) | ReactElement | null;

/** Styles for the pressable component */
pressableStyle?: StyleProp<ViewStyle>;
Expand Down Expand Up @@ -110,6 +112,8 @@ type ListItem = {

/** The search value from the selection list */
searchText?: string | null;

brickRoadIndicator?: BrickRoad | '' | null;
};

type ListItemProps = CommonListItemProps<ListItem> & {
Expand Down Expand Up @@ -214,14 +218,20 @@ type BaseSelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> & {
/** Max length for the text input */
textInputMaxLength?: number;

/** Icon to display on the left side of TextInput */
textInputIconLeft?: IconAsset;

/** Whether text input should be focused */
textInputAutoFocus?: boolean;

/** Callback to fire when the text input changes */
onChangeText?: (text: string) => void;

/** Input mode for the text input */
inputMode?: InputModeOptions;

/** Item `keyForList` to focus initially */
initiallyFocusedOptionKey?: string;
initiallyFocusedOptionKey?: string | null;

/** Callback to fire when the list is scrolled */
onScroll?: () => void;
Expand Down Expand Up @@ -272,13 +282,13 @@ type BaseSelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> & {
disableKeyboardShortcuts?: boolean;

/** Styles to apply to SelectionList container */
containerStyle?: ViewStyle;
containerStyle?: StyleProp<ViewStyle>;

/** Whether keyboard is visible on the screen */
isKeyboardShown?: boolean;

/** Component to display on the right side of each child */
rightHandSideComponent?: ((item: ListItem) => ReactElement<ListItem>) | ReactElement | null;
rightHandSideComponent?: ((item: ListItem) => ReactElement<ListItem> | null) | ReactElement | null;

/** Whether to show the loading indicator for new options */
isLoadingNewOptions?: boolean;
Expand All @@ -296,7 +306,10 @@ type BaseSelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> & {
isRowMultilineSupported?: boolean;

/** Ref for textInput */
textInputRef?: MutableRefObject<TextInput | null>;
textInputRef?: MutableRefObject<TextInput | null> | ((ref: TextInput | null) => void);

/** Styles for the section title */
sectionTitleStyles?: StyleProp<ViewStyle>;

/**
* When true, the list won't be visible until the list layout is measured. This prevents the list from "blinking" as it's scrolled to the bottom which is recommended for large lists.
Expand Down
Loading

0 comments on commit cc3c52d

Please sign in to comment.