Compare commits
7 Commits
budget-tab
...
PayeeAutoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c5a59ca1 | ||
|
|
de6f7cb59b | ||
|
|
db8e994bd0 | ||
|
|
4567235f3e | ||
|
|
4406c2515f | ||
|
|
e8508310e2 | ||
|
|
28db29ac5e |
@@ -14,6 +14,7 @@
|
||||
"./block": "./src/Block.tsx",
|
||||
"./button": "./src/Button.tsx",
|
||||
"./card": "./src/Card.tsx",
|
||||
"./combo-box": "./src/ComboBox.tsx",
|
||||
"./form-error": "./src/FormError.tsx",
|
||||
"./initial-focus": "./src/InitialFocus.ts",
|
||||
"./inline-field": "./src/InlineField.tsx",
|
||||
|
||||
198
packages/component-library/src/ComboBox.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
type ComponentProps,
|
||||
useRef,
|
||||
useContext,
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
ComboBox as AriaComboBox,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
ListBoxSection,
|
||||
type ListBoxSectionProps,
|
||||
type ComboBoxProps as AriaComboBoxProps,
|
||||
type ListBoxItemProps,
|
||||
ComboBoxStateContext as AriaComboBoxStateContext,
|
||||
type Key,
|
||||
} from 'react-aria-components';
|
||||
import { type ComboBoxState as AriaComboBoxState } from 'react-stately';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { Popover } from './Popover';
|
||||
import { styles } from './styles';
|
||||
import { theme } from './theme';
|
||||
import { View } from './View';
|
||||
|
||||
const popoverClassName = () =>
|
||||
css({
|
||||
...styles.darkScrollbar,
|
||||
...styles.popover,
|
||||
backgroundColor: theme.menuAutoCompleteBackground,
|
||||
color: theme.menuAutoCompleteText,
|
||||
padding: '5px 0',
|
||||
borderRadius: 4,
|
||||
});
|
||||
|
||||
const listBoxClassName = ({ width }: { width?: number }) =>
|
||||
css({
|
||||
width,
|
||||
minWidth: 200,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
'& [data-focused]': {
|
||||
backgroundColor: theme.menuAutoCompleteBackgroundHover,
|
||||
},
|
||||
});
|
||||
|
||||
type ComboBoxProps<T extends object> = Omit<
|
||||
AriaComboBoxProps<T>,
|
||||
'children'
|
||||
> & {
|
||||
inputPlaceholder?: string;
|
||||
children: ComponentProps<typeof ListBox<T>>['children'];
|
||||
};
|
||||
|
||||
export function ComboBox<T extends object>({
|
||||
children,
|
||||
...props
|
||||
}: ComboBoxProps<T>) {
|
||||
const viewRef = useRef<HTMLDivElement | null>(null);
|
||||
return (
|
||||
<AriaComboBox<T>
|
||||
allowsEmptyCollection
|
||||
allowsCustomValue
|
||||
menuTrigger="focus"
|
||||
{...props}
|
||||
>
|
||||
<View ref={viewRef}>
|
||||
<ComboBoxInput placeholder={props.inputPlaceholder} />
|
||||
</View>
|
||||
<Popover isNonModal className={popoverClassName()}>
|
||||
<ListBox<T>
|
||||
className={listBoxClassName({ width: viewRef.current?.clientWidth })}
|
||||
>
|
||||
{children}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
type ComboBoxInputContextValue = {
|
||||
getFocusedKey?: (state: AriaComboBoxState<unknown>) => Key | null;
|
||||
};
|
||||
|
||||
const ComboBoxInputContext = createContext<ComboBoxInputContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
type ComboBoxInputProviderProps = {
|
||||
children: ReactNode;
|
||||
getFocusedKey?: (state: AriaComboBoxState<unknown>) => Key | null;
|
||||
};
|
||||
|
||||
export function ComboBoxInputProvider({
|
||||
children,
|
||||
getFocusedKey,
|
||||
}: ComboBoxInputProviderProps) {
|
||||
return (
|
||||
<ComboBoxInputContext.Provider value={{ getFocusedKey }}>
|
||||
{children}
|
||||
</ComboBoxInputContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type ComboBoxInputProps = ComponentProps<typeof Input>;
|
||||
|
||||
function ComboBoxInput({ onKeyUp, ...props }: ComboBoxInputProps) {
|
||||
const state = useContext(AriaComboBoxStateContext);
|
||||
const _onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
state?.revert();
|
||||
}
|
||||
onKeyUp?.(e);
|
||||
};
|
||||
|
||||
const comboBoxInputContext = useContext(ComboBoxInputContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (state && state.inputValue && !state.selectionManager.focusedKey) {
|
||||
const focusedKey: Key | null =
|
||||
(comboBoxInputContext?.getFocusedKey
|
||||
? comboBoxInputContext.getFocusedKey(state)
|
||||
: defaultGetFocusedKey(state)) ?? null;
|
||||
|
||||
state.selectionManager.setFocusedKey(focusedKey);
|
||||
}
|
||||
}, [comboBoxInputContext, state, state?.inputValue]);
|
||||
|
||||
return <Input onKeyUp={_onKeyUp} {...props} />;
|
||||
}
|
||||
|
||||
function defaultGetFocusedKey<T>(state: AriaComboBoxState<T>) {
|
||||
// Focus on the first suggestion item when typing.
|
||||
const keys = Array.from(state.collection.getKeys());
|
||||
return (
|
||||
keys
|
||||
.map(key => state.collection.getItem(key))
|
||||
.find(i => i && i.type === 'item')?.key ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const defaultComboBoxSectionClassName = () =>
|
||||
css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
'& header': {
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
paddingLeft: 10,
|
||||
color: theme.menuAutoCompleteTextHeader,
|
||||
},
|
||||
});
|
||||
|
||||
type ComboBoxSectionProps<T extends object> = ListBoxSectionProps<T>;
|
||||
|
||||
export function ComboBoxSection<T extends object>({
|
||||
className,
|
||||
...props
|
||||
}: ComboBoxSectionProps<T>) {
|
||||
return (
|
||||
<ListBoxSection
|
||||
className={cx(defaultComboBoxSectionClassName(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultComboBoxItemClassName = () =>
|
||||
css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
paddingLeft: 20,
|
||||
});
|
||||
|
||||
type ComboBoxItemProps = ListBoxItemProps;
|
||||
|
||||
export function ComboBoxItem({ className, ...props }: ComboBoxItemProps) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps =>
|
||||
cx(defaultComboBoxItemClassName(), className(renderProps))
|
||||
: cx(defaultComboBoxItemClassName(), className)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -84,78 +84,6 @@ test.describe('Transactions', () => {
|
||||
);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('by payee', async () => {
|
||||
accountPage = await navigation.goToAccountPage('Capital One Checking');
|
||||
const filterTooltip = await accountPage.filterBy('Payee');
|
||||
const filtersMenuTooltip = page.getByTestId('filters-menu-tooltip');
|
||||
await expect(filterTooltip.locator).toMatchThemeScreenshots();
|
||||
|
||||
// Type in the autocomplete box
|
||||
const autocomplete = filtersMenuTooltip.getByLabel('Payee');
|
||||
await expect(autocomplete).toMatchThemeScreenshots();
|
||||
|
||||
// Open the textbox, auto-open is currently broken for anything that's not "is not"
|
||||
await autocomplete.click();
|
||||
|
||||
await page.getByTestId('Kroger-payee-item').click();
|
||||
await filterTooltip.applyButton.click();
|
||||
|
||||
// Assert that all Payees are Kroger
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).payee).toHaveText(
|
||||
'Kroger',
|
||||
);
|
||||
}
|
||||
await accountPage.removeFilter(0);
|
||||
|
||||
await accountPage.filterBy('Payee');
|
||||
await filtersMenuTooltip
|
||||
.getByRole('button', { name: 'contains' })
|
||||
.click();
|
||||
const textInput = filtersMenuTooltip.getByPlaceholder('nothing');
|
||||
|
||||
await textInput.fill('De');
|
||||
await filterTooltip.applyButton.click();
|
||||
// Assert that all Payees are Deposit
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).payee).toHaveText(
|
||||
'Deposit',
|
||||
);
|
||||
}
|
||||
|
||||
await accountPage.removeFilter(0);
|
||||
|
||||
await accountPage.filterBy('Payee');
|
||||
await filtersMenuTooltip
|
||||
.getByRole('button', { name: 'contains' })
|
||||
.click();
|
||||
|
||||
await textInput.fill('l');
|
||||
await filterTooltip.applyButton.click();
|
||||
// Assert that both Payees contain the letter 'l'
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).payee).toHaveText(/l/);
|
||||
}
|
||||
|
||||
await accountPage.removeFilter(0);
|
||||
|
||||
await accountPage.filterBy('Payee');
|
||||
await filtersMenuTooltip
|
||||
.getByRole('button', { name: 'does not contain' })
|
||||
.click();
|
||||
|
||||
await textInput.fill('l');
|
||||
await filterTooltip.applyButton.click();
|
||||
// Assert that all Payees DO NOT contain the letter 'l'
|
||||
for (let i = 0; i < 19; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).payee).not.toHaveText(
|
||||
/l/,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
test('creates a test transaction', async () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 936 B |
|
Before Width: | Height: | Size: 1023 B |
|
Before Width: | Height: | Size: 992 B |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 95 KiB |
@@ -79,7 +79,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-modal": "3.16.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "7.12.0",
|
||||
"react-router": "7.9.6",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"react-spring": "10.0.0",
|
||||
"react-swipeable": "^7.0.2",
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
@@ -50,7 +49,6 @@ export function NotesButton({
|
||||
const [tempNotes, setTempNotes] = useState<string>(note);
|
||||
useEffect(() => setTempNotes(note), [note, id]);
|
||||
|
||||
const { isFocusVisible } = useFocusVisible();
|
||||
const onOpenChange = useCallback<
|
||||
NonNullable<ComponentProps<typeof Popover>['onOpenChange']>
|
||||
>(
|
||||
@@ -89,9 +87,7 @@ export function NotesButton({
|
||||
...(isOpen && { color: theme.buttonNormalText }),
|
||||
'&:hover': { opacity: 1 },
|
||||
}),
|
||||
!hasNotes && !isOpen && !isFocusVisible && !showPlaceholder
|
||||
? 'hover-visible'
|
||||
: '',
|
||||
!hasNotes && !isOpen && !showPlaceholder ? 'hover-visible' : '',
|
||||
)}
|
||||
data-placeholder={showPlaceholder}
|
||||
onPress={() => {
|
||||
|
||||
@@ -78,10 +78,10 @@ function PrivacyOverlay({ children, ...props }) {
|
||||
display: 'inline-flex',
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
'> div:first-child': {
|
||||
' > div:first-child': {
|
||||
opacity: 0,
|
||||
},
|
||||
'> div:nth-child(2)': {
|
||||
' > div:nth-child(2)': {
|
||||
display: 'flex',
|
||||
},
|
||||
'&:hover': {
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { type ComponentProps, useMemo, useState } from 'react';
|
||||
import { Header, type Key } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
ComboBox,
|
||||
ComboBoxInputProvider,
|
||||
ComboBoxItem,
|
||||
ComboBoxSection,
|
||||
} from '@actual-app/components/combo-box';
|
||||
import { SvgAdd } from '@actual-app/components/icons/v1';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
|
||||
import {
|
||||
normalisedEquals,
|
||||
normalisedIncludes,
|
||||
} from 'loot-core/shared/normalisation';
|
||||
import { type AccountEntity, type PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCommonPayees, usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { usePrevious } from '@desktop-client/hooks/usePrevious';
|
||||
import {
|
||||
createPayee,
|
||||
getActivePayees,
|
||||
} from '@desktop-client/payees/payeesSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type PayeeComboBoxItem = PayeeEntity & {
|
||||
type: 'account' | 'payee' | 'suggested';
|
||||
};
|
||||
|
||||
type PayeeComboBoxProps = Omit<ComponentProps<typeof ComboBox>, 'children'> & {
|
||||
showInactive?: boolean;
|
||||
};
|
||||
|
||||
export function PayeeComboBox({
|
||||
showInactive,
|
||||
selectedKey,
|
||||
onOpenChange,
|
||||
onSelectionChange,
|
||||
...props
|
||||
}: PayeeComboBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
const payees = usePayees();
|
||||
const commonPayees = useCommonPayees();
|
||||
const accounts = useAccounts();
|
||||
const [focusTransferPayees] = useState(false);
|
||||
|
||||
const allPayeeSuggestions: PayeeComboBoxItem[] = useMemo(() => {
|
||||
const suggestions = getPayeeSuggestions(commonPayees, payees);
|
||||
|
||||
let filteredSuggestions: PayeeComboBoxItem[] = [...suggestions];
|
||||
|
||||
if (!showInactive) {
|
||||
filteredSuggestions = filterActivePayees(filteredSuggestions, accounts);
|
||||
}
|
||||
|
||||
if (focusTransferPayees) {
|
||||
filteredSuggestions = filterTransferPayees(filteredSuggestions);
|
||||
}
|
||||
|
||||
return filteredSuggestions;
|
||||
}, [commonPayees, payees, showInactive, focusTransferPayees, accounts]);
|
||||
|
||||
const [inputValue, setInputValue] = useState(
|
||||
getPayeeName(allPayeeSuggestions, selectedKey || null),
|
||||
);
|
||||
const [_selectedKey, setSelectedKey] = useState<Key | null>(
|
||||
selectedKey || null,
|
||||
);
|
||||
|
||||
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
|
||||
const previousIsAutocompeleteOpen = usePrevious(isAutocompleteOpen);
|
||||
const isInitialAutocompleteOpen =
|
||||
!previousIsAutocompeleteOpen && isAutocompleteOpen;
|
||||
|
||||
const filter = (textValue: string, inputValue: string) => {
|
||||
return (
|
||||
isInitialAutocompleteOpen || normalisedIncludes(textValue, inputValue)
|
||||
);
|
||||
};
|
||||
|
||||
const filteredPayeeSuggestions = allPayeeSuggestions.filter(p =>
|
||||
filter(p.name, inputValue),
|
||||
);
|
||||
|
||||
const suggestedPayees = filteredPayeeSuggestions.filter(
|
||||
p => p.type === 'suggested',
|
||||
);
|
||||
const regularPayees = filteredPayeeSuggestions.filter(
|
||||
p => !p.favorite && p.type === 'payee',
|
||||
);
|
||||
const accountPayees = filteredPayeeSuggestions.filter(
|
||||
p => p.type === 'account',
|
||||
);
|
||||
|
||||
const findExactMatchPayee = () =>
|
||||
filteredPayeeSuggestions.find(
|
||||
p => p.id !== 'new' && normalisedEquals(p.name, inputValue),
|
||||
);
|
||||
|
||||
const exactMatchPayee = findExactMatchPayee();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const onCreatePayee = () => {
|
||||
return dispatch(createPayee({ name: inputValue })).unwrap();
|
||||
};
|
||||
|
||||
const _onSelectionChange = async (id: Key | null) => {
|
||||
if (id === 'new') {
|
||||
const newPayeeId = await onCreatePayee?.();
|
||||
setSelectedKey(newPayeeId);
|
||||
onSelectionChange?.(newPayeeId);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedKey(id);
|
||||
setInputValue(getPayeeName(filteredPayeeSuggestions, id));
|
||||
onSelectionChange?.(id);
|
||||
};
|
||||
|
||||
const _onOpenChange = (isOpen: boolean) => {
|
||||
setIsAutocompleteOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
const getFocusedKey: ComponentProps<
|
||||
typeof ComboBoxInputProvider
|
||||
>['getFocusedKey'] = state => {
|
||||
const keys = Array.from(state.collection.getKeys());
|
||||
const found = keys
|
||||
.map(key => state.collection.getItem(key))
|
||||
.find(i => i && i.type === 'item' && i.key !== 'new');
|
||||
|
||||
// Focus on the first suggestion item when typing.
|
||||
// Otherwise, if there are no results, focus on the "new" item to allow creating a new entry.
|
||||
return found?.key || 'new';
|
||||
};
|
||||
|
||||
return (
|
||||
<ComboBoxInputProvider getFocusedKey={getFocusedKey}>
|
||||
<ComboBox
|
||||
aria-label={t('Payee autocomplete')}
|
||||
inputPlaceholder="nothing"
|
||||
inputValue={inputValue}
|
||||
onInputChange={setInputValue}
|
||||
selectedKey={_selectedKey || selectedKey}
|
||||
onSelectionChange={_onSelectionChange}
|
||||
onOpenChange={_onOpenChange}
|
||||
{...props}
|
||||
>
|
||||
<PayeeList
|
||||
showCreatePayee={!!inputValue && !exactMatchPayee}
|
||||
inputValue={inputValue}
|
||||
suggestedPayees={suggestedPayees}
|
||||
regularPayees={regularPayees}
|
||||
accountPayees={accountPayees}
|
||||
/>
|
||||
|
||||
{/* <ComboBoxSection className={css({ position: 'sticky', bottom: 0, })}>
|
||||
<Button variant="menu" slot={null}>
|
||||
<Trans>Make transfer</Trans>
|
||||
</Button>
|
||||
<Button variant="menu" slot={null}>
|
||||
<Trans>Manage payees</Trans>
|
||||
</Button>
|
||||
</ComboBoxSection> */}
|
||||
</ComboBox>
|
||||
</ComboBoxInputProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type PayeeListProps = {
|
||||
showCreatePayee: boolean;
|
||||
inputValue: string;
|
||||
suggestedPayees: PayeeComboBoxItem[];
|
||||
regularPayees: PayeeComboBoxItem[];
|
||||
accountPayees: PayeeComboBoxItem[];
|
||||
};
|
||||
|
||||
function PayeeList({
|
||||
showCreatePayee,
|
||||
inputValue,
|
||||
suggestedPayees,
|
||||
regularPayees,
|
||||
accountPayees,
|
||||
}: PayeeListProps) {
|
||||
return (
|
||||
<>
|
||||
{showCreatePayee && (
|
||||
<ComboBoxItem
|
||||
key="new"
|
||||
id="new"
|
||||
textValue={inputValue}
|
||||
style={{ paddingLeft: 10, color: theme.noticeText }}
|
||||
>
|
||||
<SvgAdd width={8} height={8} style={{ marginRight: 5 }} />
|
||||
Create payee: {inputValue}
|
||||
</ComboBoxItem>
|
||||
)}
|
||||
|
||||
{suggestedPayees.length > 0 && (
|
||||
<ComboBoxSection>
|
||||
<Header>
|
||||
<Trans>Suggested Payees</Trans>
|
||||
</Header>
|
||||
{suggestedPayees.map(payee => (
|
||||
<ComboBoxItem
|
||||
key={payee.id}
|
||||
id={payee.id}
|
||||
textValue={payee.name}
|
||||
value={payee}
|
||||
>
|
||||
{payee.name}
|
||||
</ComboBoxItem>
|
||||
))}
|
||||
</ComboBoxSection>
|
||||
)}
|
||||
|
||||
{regularPayees.length > 0 && (
|
||||
<ComboBoxSection>
|
||||
<Header>
|
||||
<Trans>Payees</Trans>
|
||||
</Header>
|
||||
{regularPayees.map(payee => (
|
||||
<ComboBoxItem
|
||||
key={payee.id}
|
||||
id={payee.id}
|
||||
textValue={payee.name}
|
||||
value={payee}
|
||||
>
|
||||
{payee.name}
|
||||
</ComboBoxItem>
|
||||
))}
|
||||
</ComboBoxSection>
|
||||
)}
|
||||
|
||||
{accountPayees.length > 0 && (
|
||||
<ComboBoxSection>
|
||||
<Header>
|
||||
<Trans>Transfer To/From</Trans>
|
||||
</Header>
|
||||
{accountPayees.map(payee => (
|
||||
<ComboBoxItem
|
||||
key={payee.id}
|
||||
id={payee.id}
|
||||
textValue={payee.name}
|
||||
value={payee}
|
||||
>
|
||||
{payee.name}
|
||||
</ComboBoxItem>
|
||||
))}
|
||||
</ComboBoxSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_AUTO_SUGGESTIONS = 5;
|
||||
|
||||
function getPayeeSuggestions(
|
||||
commonPayees: PayeeEntity[],
|
||||
payees: PayeeEntity[],
|
||||
): PayeeComboBoxItem[] {
|
||||
const favoritePayees = payees
|
||||
.filter(p => p.favorite)
|
||||
.map(p => {
|
||||
return { ...p, type: determineType(p, true) };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let additionalCommonPayees: PayeeComboBoxItem[] = [];
|
||||
if (commonPayees?.length > 0) {
|
||||
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
|
||||
additionalCommonPayees = commonPayees
|
||||
.filter(
|
||||
p => !(p.favorite || favoritePayees.map(fp => fp.id).includes(p.id)),
|
||||
)
|
||||
.slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length)
|
||||
.map(p => {
|
||||
return { ...p, type: determineType(p, true) };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
}
|
||||
|
||||
if (favoritePayees.length + additionalCommonPayees.length) {
|
||||
const filteredPayees: PayeeComboBoxItem[] = payees
|
||||
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
|
||||
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
|
||||
.map<PayeeComboBoxItem>(p => {
|
||||
return { ...p, type: determineType(p, false) };
|
||||
});
|
||||
|
||||
return favoritePayees.concat(additionalCommonPayees).concat(filteredPayees);
|
||||
}
|
||||
|
||||
return payees.map(p => {
|
||||
return { ...p, type: determineType(p, false) };
|
||||
});
|
||||
}
|
||||
|
||||
function filterActivePayees<T extends PayeeEntity>(
|
||||
payees: T[],
|
||||
accounts: AccountEntity[],
|
||||
): T[] {
|
||||
return accounts ? (getActivePayees(payees, accounts) as T[]) : payees;
|
||||
}
|
||||
|
||||
function filterTransferPayees(payees: PayeeComboBoxItem[]) {
|
||||
return payees.filter(payee => !!payee.transfer_acct);
|
||||
}
|
||||
|
||||
function determineType(
|
||||
payee: PayeeEntity,
|
||||
isCommon: boolean,
|
||||
): PayeeComboBoxItem['type'] {
|
||||
if (payee.transfer_acct) {
|
||||
return 'account';
|
||||
}
|
||||
if (isCommon) {
|
||||
return 'suggested';
|
||||
} else {
|
||||
return 'payee';
|
||||
}
|
||||
}
|
||||
|
||||
function getPayeeName<T extends PayeeEntity>(items: T[], id: Key | null) {
|
||||
return items.find(p => p.id === id)?.name || '';
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { BudgetCategories } from './BudgetCategories';
|
||||
import { BudgetCategories as BudgetCategoriesV2 } from './BudgetCategoriesV2';
|
||||
import { BudgetSummaries } from './BudgetSummaries';
|
||||
import { BudgetTotals } from './BudgetTotals';
|
||||
import { type MonthBounds, MonthsProvider } from './MonthsContext';
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import { type DropPosition } from '@desktop-client/components/sort';
|
||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
|
||||
@@ -84,7 +82,6 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
|
||||
null,
|
||||
);
|
||||
const budgetTableV2Enabled = useFeatureFlag('budgetTableV2');
|
||||
|
||||
const onEditMonth = (id: string, month: string) => {
|
||||
setEditing(id ? { id, cell: month } : null);
|
||||
@@ -208,11 +205,15 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
setShowHiddenCategoriesPef(!showHiddenCategories);
|
||||
};
|
||||
|
||||
const onExpandAllCategories = () => {
|
||||
const toggleHiddenCategories = () => {
|
||||
onToggleHiddenCategories();
|
||||
};
|
||||
|
||||
const expandAllCategories = () => {
|
||||
onCollapse([]);
|
||||
};
|
||||
|
||||
const onCollapseAllCategories = () => {
|
||||
const collapseAllCategories = () => {
|
||||
onCollapse(categoryGroups.map(g => g.id));
|
||||
};
|
||||
|
||||
@@ -261,62 +262,45 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
monthBounds={monthBounds}
|
||||
type={type}
|
||||
>
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
{!budgetTableV2Enabled && (
|
||||
<>
|
||||
<BudgetTotals
|
||||
toggleHiddenCategories={onToggleHiddenCategories}
|
||||
expandAllCategories={onExpandAllCategories}
|
||||
collapseAllCategories={onCollapseAllCategories}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
overflowAnchor: 'none',
|
||||
flex: 1,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<BudgetCategories
|
||||
categoryGroups={categoryGroups}
|
||||
editingCell={editing}
|
||||
onEditMonth={onEditMonth}
|
||||
onEditName={onEditName}
|
||||
onSaveCategory={onSaveCategory}
|
||||
onSaveGroup={onSaveGroup}
|
||||
onDeleteCategory={onDeleteCategory}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onReorderCategory={_onReorderCategory}
|
||||
onReorderGroup={_onReorderGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
onApplyBudgetTemplatesInGroup={
|
||||
onApplyBudgetTemplatesInGroup
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{budgetTableV2Enabled && (
|
||||
<View style={{ overflowY: 'auto' }}>
|
||||
<BudgetCategoriesV2
|
||||
<BudgetTotals
|
||||
toggleHiddenCategories={toggleHiddenCategories}
|
||||
expandAllCategories={expandAllCategories}
|
||||
collapseAllCategories={collapseAllCategories}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
overflowAnchor: 'none',
|
||||
flex: 1,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<BudgetCategories
|
||||
categoryGroups={categoryGroups}
|
||||
editingCell={editing}
|
||||
onEditMonth={onEditMonth}
|
||||
onEditName={onEditName}
|
||||
onSaveCategory={onSaveCategory}
|
||||
onSaveGroup={onSaveGroup}
|
||||
onDeleteCategory={onDeleteCategory}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onReorderCategory={_onReorderCategory}
|
||||
onReorderGroup={_onReorderGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
onToggleHiddenCategories={onToggleHiddenCategories}
|
||||
onExpandAllCategories={onExpandAllCategories}
|
||||
onCollapseAllCategories={onCollapseAllCategories}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</SchedulesProvider>
|
||||
</SchedulesProvider>
|
||||
</View>
|
||||
</View>
|
||||
</MonthsProvider>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { usePress, useFocusable } from 'react-aria';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import { SvgArrowThinRight } from '@actual-app/components/icons/v1';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { type CSSProperties } from '@actual-app/components/styles';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { type CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import { balanceColumnPaddingStyle } from './BudgetCategoriesV2';
|
||||
import { BalanceMovementMenu as EnvelopeBalanceMovementMenu } from './envelope/BalanceMovementMenu';
|
||||
import { BalanceMenu as TrackingBalanceMenu } from './tracking/BalanceMenu';
|
||||
import { makeBalanceAmountStyle } from './util';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryBalanceCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
category: CategoryEntity;
|
||||
onBudgetAction: (month: string, action: string, args: unknown) => void;
|
||||
};
|
||||
|
||||
export function CategoryBalanceCell({
|
||||
month,
|
||||
category,
|
||||
onBudgetAction,
|
||||
style,
|
||||
...props
|
||||
}: CategoryBalanceCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const categoryCarryoverBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catCarryover(category.id)
|
||||
: trackingBudget.catCarryover(category.id);
|
||||
|
||||
const categoryBalanceBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catBalance(category.id)
|
||||
: trackingBudget.catBalance(category.id);
|
||||
|
||||
const categoryBudgetedBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catBudgeted(category.id)
|
||||
: trackingBudget.catBudgeted(category.id);
|
||||
|
||||
const categoryGoalBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catGoal(category.id)
|
||||
: trackingBudget.catGoal(category.id);
|
||||
|
||||
const categoryLongGoalBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catLongGoal(category.id)
|
||||
: trackingBudget.catLongGoal(category.id);
|
||||
|
||||
const budgetedValue = useSheetValue<
|
||||
typeof bindingBudgetType,
|
||||
typeof categoryBudgetedBinding
|
||||
>(categoryBudgetedBinding);
|
||||
|
||||
const goalValue = useSheetValue<
|
||||
typeof bindingBudgetType,
|
||||
typeof categoryGoalBinding
|
||||
>(categoryGoalBinding);
|
||||
|
||||
const longGoalValue = useSheetValue<
|
||||
typeof bindingBudgetType,
|
||||
typeof categoryLongGoalBinding
|
||||
>(categoryLongGoalBinding);
|
||||
|
||||
const [isBalanceMenuOpen, setIsBalanceMenuOpen] = useState(false);
|
||||
|
||||
const { pressProps } = usePress({
|
||||
onPress: () => setIsBalanceMenuOpen(true),
|
||||
});
|
||||
|
||||
const { focusableProps } = useFocusable(
|
||||
{
|
||||
onKeyUp: e => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsBalanceMenuOpen(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
triggerRef,
|
||||
);
|
||||
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
|
||||
const getBalanceAmountStyle = useCallback(
|
||||
(balanceValue: number) =>
|
||||
makeBalanceAmountStyle(
|
||||
balanceValue,
|
||||
isGoalTemplatesEnabled ? goalValue : null,
|
||||
longGoalValue === 1 ? balanceValue : budgetedValue,
|
||||
),
|
||||
[budgetedValue, goalValue, isGoalTemplatesEnabled, longGoalValue],
|
||||
);
|
||||
|
||||
// TODO: Refactor balance cell tooltips
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<typeof bindingBudgetType, typeof categoryBalanceBinding>
|
||||
type="financial"
|
||||
binding={categoryBalanceBinding}
|
||||
>
|
||||
{balanceProps => (
|
||||
<View
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
...balanceColumnPaddingStyle,
|
||||
}}
|
||||
>
|
||||
<CellValueText
|
||||
innerRef={triggerRef}
|
||||
{...pressProps}
|
||||
{...focusableProps}
|
||||
{...balanceProps}
|
||||
className={css({
|
||||
...getBalanceAmountStyle(balanceProps.value),
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<CellValue<
|
||||
typeof bindingBudgetType,
|
||||
typeof categoryCarryoverBinding
|
||||
>
|
||||
binding={categoryCarryoverBinding}
|
||||
>
|
||||
{carryOverProps =>
|
||||
carryOverProps.value && (
|
||||
<CarryoverIndicator
|
||||
style={getBalanceAmountStyle(balanceProps.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</CellValue>
|
||||
</View>
|
||||
)}
|
||||
</CellValue>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
placement="bottom end"
|
||||
isOpen={isBalanceMenuOpen}
|
||||
onOpenChange={() => {
|
||||
setIsBalanceMenuOpen(false);
|
||||
}}
|
||||
isNonModal
|
||||
>
|
||||
{budgetType === 'rollover' ? (
|
||||
<EnvelopeBalanceMovementMenu
|
||||
categoryId={category.id}
|
||||
month={month}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onSelect={() => setIsBalanceMenuOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<TrackingBalanceMenu
|
||||
categoryId={category.id}
|
||||
onCarryover={carryover => {
|
||||
onBudgetAction(month, 'carryover', {
|
||||
category: category.id,
|
||||
flag: carryover,
|
||||
});
|
||||
setIsBalanceMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
type CarryoverIndicatorProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
function CarryoverIndicator({ style }: CarryoverIndicatorProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
transform: 'translateY(-50%)',
|
||||
top: '50%',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<SvgArrowThinRight
|
||||
width={style?.width || 7}
|
||||
height={style?.height || 7}
|
||||
style={style}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef, useState } from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import {
|
||||
currencyToAmount,
|
||||
currencyToInteger,
|
||||
type IntegerAmount,
|
||||
} from 'loot-core/shared/util';
|
||||
import type { CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
hoverVisibleStyle,
|
||||
getCellBackgroundStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { BudgetMenu as EnvelopeBudgetMenu } from './envelope/BudgetMenu';
|
||||
import { BudgetMenu as TrackingBudgetMenu } from './tracking/BudgetMenu';
|
||||
import { makeAmountGrey } from './util';
|
||||
|
||||
import { CellValue } from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryBudgetedCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
category: CategoryEntity;
|
||||
onBudgetAction: (month: string, action: string, args: unknown) => void;
|
||||
};
|
||||
|
||||
export function CategoryBudgetedCell({
|
||||
month,
|
||||
category,
|
||||
onBudgetAction,
|
||||
...props
|
||||
}: CategoryBudgetedCellProps) {
|
||||
const { t } = useTranslation();
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [shouldHideBudgetMenuButton, setShouldHideBudgetMenuButton] =
|
||||
useState(false);
|
||||
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const budgetedBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catBudgeted(category.id)
|
||||
: trackingBudget.catBudgeted(category.id);
|
||||
|
||||
const BudgetMenuComponent =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? EnvelopeBudgetMenu
|
||||
: TrackingBudgetMenu;
|
||||
|
||||
const { showUndoNotification } = useUndo();
|
||||
|
||||
const onUpdateBudget = (amount: IntegerAmount) => {
|
||||
onBudgetAction(month, 'budget-amount', {
|
||||
category: category.id,
|
||||
amount,
|
||||
});
|
||||
};
|
||||
|
||||
const { isFocusVisible } = useFocusVisible();
|
||||
|
||||
return (
|
||||
<ReactAriaCell {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<View
|
||||
className={css({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
...hoverVisibleStyle,
|
||||
})}
|
||||
>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Budget menu')}
|
||||
className={cx(
|
||||
{ 'hover-visible': !isMenuOpen && !isFocusVisible },
|
||||
css({
|
||||
display:
|
||||
shouldHideBudgetMenuButton && !isFocusVisible
|
||||
? 'none'
|
||||
: undefined,
|
||||
}),
|
||||
)}
|
||||
onPress={() => setIsMenuOpen(true)}
|
||||
>
|
||||
<SvgCheveronDown width={12} height={12} />
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
placement="bottom start"
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={() => setIsMenuOpen(false)}
|
||||
isNonModal
|
||||
>
|
||||
<BudgetMenuComponent
|
||||
onCopyLastMonthAverage={() => {
|
||||
onBudgetAction(month, 'copy-single-last', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget set to last month's budget.`),
|
||||
});
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
if (
|
||||
numberOfMonths !== 3 &&
|
||||
numberOfMonths !== 6 &&
|
||||
numberOfMonths !== 12
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Budget set to {{numberOfMonths}}-month average.',
|
||||
{ numberOfMonths },
|
||||
),
|
||||
});
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
onApplyBudgetTemplate={() => {
|
||||
onBudgetAction(month, 'apply-single-category-template', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget template applied.`),
|
||||
});
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
<View style={{ flex: 1 }}>
|
||||
<CellValue<typeof bindingBudgetType, typeof budgetedBinding>
|
||||
type="financial"
|
||||
binding={budgetedBinding}
|
||||
>
|
||||
{({ value: budgetedAmount }) => (
|
||||
<BudgetedInput
|
||||
value={budgetedAmount}
|
||||
onFocus={() => setShouldHideBudgetMenuButton(true)}
|
||||
onBlur={() => setShouldHideBudgetMenuButton(false)}
|
||||
style={getCellBackgroundStyle('budgeted', month)}
|
||||
onUpdateAmount={onUpdateBudget}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</View>
|
||||
</View>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
|
||||
type BudgetedInputProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof Input>,
|
||||
'value'
|
||||
> & {
|
||||
value: IntegerAmount;
|
||||
onUpdateAmount: (newValue: IntegerAmount) => void;
|
||||
};
|
||||
|
||||
function BudgetedInput({
|
||||
value,
|
||||
onFocus,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
onUpdateAmount,
|
||||
...props
|
||||
}: BudgetedInputProps) {
|
||||
const format = useFormat();
|
||||
const [currentFormattedAmount, setCurrentFormattedAmount] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={currentFormattedAmount ?? format(value, 'financial')}
|
||||
onFocus={e => {
|
||||
onFocus?.(e);
|
||||
if (!e.defaultPrevented) {
|
||||
e.target.select();
|
||||
}
|
||||
}}
|
||||
onEscape={() => setCurrentFormattedAmount(format(value, 'financial'))}
|
||||
className={css({
|
||||
...makeAmountGrey(
|
||||
currentFormattedAmount
|
||||
? currencyToAmount(currentFormattedAmount)
|
||||
: value,
|
||||
),
|
||||
textAlign: 'right',
|
||||
border: '1px solid transparent',
|
||||
'&:hover:not(:focus)': {
|
||||
border: `1px solid ${theme.formInputBorder}`,
|
||||
},
|
||||
})}
|
||||
onChangeValue={(newValue, e) => {
|
||||
onChangeValue?.(newValue, e);
|
||||
setCurrentFormattedAmount(newValue);
|
||||
}}
|
||||
onUpdate={(newValue, e) => {
|
||||
onUpdate?.(newValue, e);
|
||||
const integerAmount = currencyToInteger(newValue);
|
||||
if (integerAmount) {
|
||||
onUpdateAmount?.(integerAmount);
|
||||
setCurrentFormattedAmount(format(integerAmount, 'financial'));
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import { balanceColumnPaddingStyle } from './BudgetCategoriesV2';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryGroupBalanceCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
};
|
||||
|
||||
export function CategoryGroupBalanceCell({
|
||||
month,
|
||||
categoryGroup,
|
||||
style,
|
||||
...props
|
||||
}: CategoryGroupBalanceCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const groupBalanceBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.groupBalance(categoryGroup.id)
|
||||
: trackingBudget.groupBalance(categoryGroup.id);
|
||||
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<typeof bindingBudgetType, typeof groupBalanceBinding>
|
||||
type="financial"
|
||||
binding={groupBalanceBinding}
|
||||
>
|
||||
{props => (
|
||||
<CellValueText
|
||||
{...props}
|
||||
style={{
|
||||
...balanceColumnPaddingStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryGroupBudgetedCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
};
|
||||
|
||||
export function CategoryGroupBudgetedCell({
|
||||
month,
|
||||
categoryGroup,
|
||||
style,
|
||||
...props
|
||||
}: CategoryGroupBudgetedCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const groupBudgetedBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.groupBudgeted(categoryGroup.id)
|
||||
: trackingBudget.groupBudgeted(categoryGroup.id);
|
||||
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<typeof bindingBudgetType, typeof groupBudgetedBinding>
|
||||
type="financial"
|
||||
binding={groupBudgetedBinding}
|
||||
>
|
||||
{props => (
|
||||
<CellValueText
|
||||
{...props}
|
||||
style={{
|
||||
paddingRight: 5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef, useState } from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgAdd, SvgExpandArrow } from '@actual-app/components/icons/v0';
|
||||
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import { hoverVisibleStyle } from './BudgetCategoriesV2';
|
||||
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
|
||||
type CategoryGroupNameCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
|
||||
onDelete: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
|
||||
};
|
||||
|
||||
export function CategoryGroupNameCell({
|
||||
month,
|
||||
categoryGroup,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onAddCategory,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibilty,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
...props
|
||||
}: CategoryGroupNameCellProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const { isFocusVisible } = useFocusVisible();
|
||||
|
||||
return (
|
||||
<ReactAriaCell {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<View
|
||||
className={css({
|
||||
paddingLeft: 5,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...hoverVisibleStyle,
|
||||
})}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||
{/* Hidden drag button */}
|
||||
<Button
|
||||
slot="drag"
|
||||
style={{
|
||||
opacity: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onToggleCollapse(categoryGroup)}
|
||||
isDisabled={isRenaming}
|
||||
>
|
||||
<SvgExpandArrow
|
||||
width={8}
|
||||
height={8}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
transition: 'transform .1s',
|
||||
transform: isCollapsed ? 'rotate(-90deg)' : '',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{isRenaming ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
autoFocus
|
||||
defaultValue={categoryGroup.name}
|
||||
onBlur={() => setIsRenaming(false)}
|
||||
onEscape={() => setIsRenaming(false)}
|
||||
onUpdate={newName => {
|
||||
if (newName !== categoryGroup.name) {
|
||||
onRename(categoryGroup, newName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ fontWeight: 600 }}>{categoryGroup.name}</Text>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
className={cx({
|
||||
'hover-visible': !isMenuOpen && !isFocusVisible,
|
||||
})}
|
||||
onPress={() => {
|
||||
// resetPosition();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<SvgCheveronDown width={12} height={12} />
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
placement="bottom start"
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={() => setIsMenuOpen(false)}
|
||||
isNonModal
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={type => {
|
||||
if (type === 'rename') {
|
||||
// onEdit(categoryGroup.id);
|
||||
setIsRenaming(true);
|
||||
} else if (type === 'delete') {
|
||||
onDelete(categoryGroup);
|
||||
} else if (type === 'toggle-visibility') {
|
||||
// onSave({ ...categoryGroup, hidden: !categoryGroup.hidden });
|
||||
onToggleVisibilty(categoryGroup);
|
||||
} else if (
|
||||
type === 'apply-multiple-category-template'
|
||||
) {
|
||||
onApplyBudgetTemplatesInGroup(categoryGroup);
|
||||
}
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
items={[
|
||||
{ name: 'rename', text: t('Rename') },
|
||||
...(!categoryGroup.is_income
|
||||
? [
|
||||
{
|
||||
name: 'toggle-visibility',
|
||||
text: categoryGroup.hidden ? 'Show' : 'Hide',
|
||||
},
|
||||
{ name: 'delete', text: t('Delete') },
|
||||
]
|
||||
: []),
|
||||
...(isGoalTemplatesEnabled
|
||||
? [
|
||||
{
|
||||
name: 'apply-multiple-category-template',
|
||||
text: t('Overwrite with templates'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
{!isRenaming && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Tooltip content={t('Add category')} disablePointerEvents>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Add category')}
|
||||
className={cx(
|
||||
css({
|
||||
color: theme.pageTextLight,
|
||||
}),
|
||||
'hover-visible',
|
||||
)}
|
||||
onPress={() => {
|
||||
onAddCategory(categoryGroup);
|
||||
}}
|
||||
>
|
||||
<SvgAdd style={{ width: 10, height: 10, flexShrink: 0 }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<NotesButton
|
||||
id={categoryGroup.id}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryGroupSpentCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
};
|
||||
|
||||
export function CategoryGroupSpentCell({
|
||||
month,
|
||||
categoryGroup,
|
||||
style,
|
||||
...props
|
||||
}: CategoryGroupSpentCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const groupSpentBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.groupSumAmount(categoryGroup.id)
|
||||
: trackingBudget.groupSumAmount(categoryGroup.id);
|
||||
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<typeof bindingBudgetType, typeof groupSpentBinding>
|
||||
type="financial"
|
||||
binding={groupSpentBinding}
|
||||
>
|
||||
{props => (
|
||||
<CellValueText
|
||||
{...props}
|
||||
style={{
|
||||
paddingRight: 5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef, useState } from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type {
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { hoverVisibleStyle } from './BudgetCategoriesV2';
|
||||
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
|
||||
type CategoryNameCellProps = ComponentPropsWithoutRef<typeof ReactAriaCell> & {
|
||||
month: string;
|
||||
category: CategoryEntity;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
onRename: (category: CategoryEntity, newName: string) => void;
|
||||
onDelete: (category: CategoryEntity) => void;
|
||||
onToggleVisibility: (category: CategoryEntity) => void;
|
||||
};
|
||||
|
||||
export function CategoryNameCell({
|
||||
month,
|
||||
category,
|
||||
categoryGroup,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
...props
|
||||
}: CategoryNameCellProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const { isFocusVisible } = useFocusVisible();
|
||||
|
||||
return (
|
||||
<ReactAriaCell {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<View
|
||||
className={css({
|
||||
paddingLeft: 18,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...hoverVisibleStyle,
|
||||
})}
|
||||
>
|
||||
{/* Hidden drag button */}
|
||||
<Button
|
||||
slot="drag"
|
||||
style={{
|
||||
opacity: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
{isRenaming ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
defaultValue={category.name}
|
||||
placeholder={t('Enter category name')}
|
||||
onBlur={() => setIsRenaming(false)}
|
||||
onUpdate={newName => {
|
||||
if (newName !== category.name) {
|
||||
onRename(category, newName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<Text>{category.name}</Text>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
className={cx({
|
||||
'hover-visible': !isMenuOpen && !isFocusVisible,
|
||||
})}
|
||||
onPress={() => {
|
||||
// resetPosition();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<SvgCheveronDown width={12} height={12} />
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
placement="bottom start"
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={() => setIsMenuOpen(false)}
|
||||
isNonModal
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={type => {
|
||||
if (type === 'rename') {
|
||||
// onEditName(category.id);
|
||||
setIsRenaming(true);
|
||||
} else if (type === 'delete') {
|
||||
onDelete(category);
|
||||
} else if (type === 'toggle-visibility') {
|
||||
// onSave({ ...category, hidden: !category.hidden });
|
||||
onToggleVisibility(category);
|
||||
}
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
items={[
|
||||
{ name: 'rename', text: t('Rename') },
|
||||
...(!categoryGroup?.hidden
|
||||
? [
|
||||
{
|
||||
name: 'toggle-visibility',
|
||||
text: category.hidden ? t('Show') : t('Hide'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ name: 'delete', text: t('Delete') },
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</View>
|
||||
<View>
|
||||
<NotesButton
|
||||
id={category.id}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef, useRef } from 'react';
|
||||
import { usePress, useFocusable } from 'react-aria';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import { makeAmountGrey } from './util';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategorySpentCellProps = ComponentPropsWithoutRef<typeof ReactAriaCell> & {
|
||||
month: string;
|
||||
category: CategoryEntity;
|
||||
onShowActivity: (category: CategoryEntity, month: string) => void;
|
||||
};
|
||||
|
||||
export function CategorySpentCell({
|
||||
month,
|
||||
category,
|
||||
onShowActivity,
|
||||
style,
|
||||
...props
|
||||
}: CategorySpentCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
|
||||
const categorySpentBinding =
|
||||
budgetType === 'rollover'
|
||||
? envelopeBudget.catSumAmount(category.id)
|
||||
: trackingBudget.catSumAmount(category.id);
|
||||
|
||||
const { pressProps } = usePress({
|
||||
onPress: () => onShowActivity(category, month),
|
||||
});
|
||||
|
||||
const textRef = useRef<HTMLSpanElement | null>(null);
|
||||
const { focusableProps } = useFocusable(
|
||||
{
|
||||
onKeyUp: e => {
|
||||
if (e.key === 'Enter') {
|
||||
onShowActivity(category, month);
|
||||
}
|
||||
},
|
||||
},
|
||||
textRef,
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<'envelope-budget', 'sum-amount'>
|
||||
type="financial"
|
||||
binding={categorySpentBinding}
|
||||
>
|
||||
{props => (
|
||||
<CellValueText
|
||||
innerRef={textRef}
|
||||
{...pressProps}
|
||||
{...focusableProps}
|
||||
{...props}
|
||||
className={css({
|
||||
...makeAmountGrey(props.value),
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
paddingRight: 5,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { useBudgetMonthCount } from './BudgetMonthCountContext';
|
||||
import { BudgetPageHeader } from './BudgetPageHeader';
|
||||
import { BudgetTable } from './BudgetTable';
|
||||
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
|
||||
function getNumPossibleMonths(width: number, categoryWidth: number) {
|
||||
@@ -79,12 +78,8 @@ const DynamicBudgetTable = ({
|
||||
onMonthSelect(getValidMonth(month), numMonths);
|
||||
}
|
||||
|
||||
// Table V2 uses alt+left/right for month navigation
|
||||
// so that users can use left/right to navigate cells
|
||||
const budgetTableV2Enabled = useFeatureFlag('budgetTableV2');
|
||||
|
||||
useHotkeys(
|
||||
budgetTableV2Enabled ? 'alt+left' : 'left',
|
||||
'left',
|
||||
() => {
|
||||
_onMonthSelect(monthUtils.prevMonth(startMonth));
|
||||
},
|
||||
@@ -95,7 +90,7 @@ const DynamicBudgetTable = ({
|
||||
[_onMonthSelect, startMonth],
|
||||
);
|
||||
useHotkeys(
|
||||
budgetTableV2Enabled ? 'alt+right' : 'right',
|
||||
'right',
|
||||
() => {
|
||||
_onMonthSelect(monthUtils.nextMonth(startMonth));
|
||||
},
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Row as ReactAriaRow } from 'react-aria-components';
|
||||
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getCategoryGroupRowStyle,
|
||||
getHeaderBackgroundStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { CategoryGroupBalanceCell } from './CategoryGroupBalanceCell';
|
||||
import { CategoryGroupBudgetedCell } from './CategoryGroupBudgetedCell';
|
||||
import { CategoryGroupNameCell } from './CategoryGroupNameCell';
|
||||
import { CategoryGroupSpentCell } from './CategoryGroupSpentCell';
|
||||
|
||||
type ExpenseCategoryGroupRowProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaRow<ColumnDefinition>
|
||||
> & {
|
||||
item: {
|
||||
type: 'expense-group';
|
||||
id: `expense-group-${string}`;
|
||||
value: CategoryGroupEntity;
|
||||
};
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
|
||||
onDelete: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
|
||||
};
|
||||
|
||||
export function ExpenseCategoryGroupRow({
|
||||
item,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onAddCategory,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibilty,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
style,
|
||||
...props
|
||||
}: ExpenseCategoryGroupRowProps) {
|
||||
return (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryGroupRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryGroupNameCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onAddCategory={onAddCategory}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibilty={onToggleVisibilty}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return (
|
||||
<CategoryGroupBudgetedCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
style={getHeaderBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
case 'spent':
|
||||
return (
|
||||
<CategoryGroupSpentCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
style={getHeaderBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryGroupBalanceCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
style={getHeaderBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Row as ReactAriaRow } from 'react-aria-components';
|
||||
|
||||
import type {
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getCategoryRowStyle,
|
||||
getCellBackgroundStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { CategoryBalanceCell } from './CategoryBalanceCell';
|
||||
import { CategoryBudgetedCell } from './CategoryBudgetedCell';
|
||||
import { CategoryNameCell } from './CategoryNameCell';
|
||||
import { CategorySpentCell } from './CategorySpentCell';
|
||||
|
||||
type ExpenseCategoryRowProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaRow<ColumnDefinition>
|
||||
> & {
|
||||
item: {
|
||||
type: 'expense-category';
|
||||
id: `expense-category-${string}`;
|
||||
value: CategoryEntity;
|
||||
group: CategoryGroupEntity;
|
||||
};
|
||||
onBudgetAction: (month: string, action: string, args: unknown) => void;
|
||||
onShowActivity: (category: CategoryEntity, month: string) => void;
|
||||
onRename: (category: CategoryEntity, newName: string) => void;
|
||||
onDelete: (category: CategoryEntity) => void;
|
||||
onToggleVisibility: (category: CategoryEntity) => void;
|
||||
};
|
||||
export function ExpenseCategoryRow({
|
||||
item,
|
||||
onBudgetAction,
|
||||
onShowActivity,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
style,
|
||||
...props
|
||||
}: ExpenseCategoryRowProps) {
|
||||
return (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryRowStyle(item.value, item.group),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryNameCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
categoryGroup={item.group}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return (
|
||||
<CategoryBudgetedCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
style={getCellBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
case 'spent':
|
||||
return (
|
||||
<CategorySpentCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onShowActivity={onShowActivity}
|
||||
style={getCellBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryBalanceCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
style={getCellBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import {
|
||||
Row as ReactAriaRow,
|
||||
Cell as ReactAriaCell,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getCategoryGroupRowStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { CategoryGroupBalanceCell } from './CategoryGroupBalanceCell';
|
||||
import { CategoryGroupBudgetedCell } from './CategoryGroupBudgetedCell';
|
||||
import { CategoryGroupNameCell } from './CategoryGroupNameCell';
|
||||
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
|
||||
type IncomeCategoryGroupRowProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaRow<ColumnDefinition>
|
||||
> & {
|
||||
item: {
|
||||
type: 'income-group';
|
||||
id: `income-group-${string}`;
|
||||
value: CategoryGroupEntity;
|
||||
};
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
|
||||
onDelete: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
|
||||
};
|
||||
|
||||
export function IncomeCategoryGroupRow({
|
||||
item,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onAddCategory,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibilty,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
style,
|
||||
...props
|
||||
}: IncomeCategoryGroupRowProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
|
||||
return budgetType === 'rollover' ? (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryGroupRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryGroupNameCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onAddCategory={onAddCategory}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibilty={onToggleVisibilty}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return <ReactAriaCell />;
|
||||
case 'spent':
|
||||
return <ReactAriaCell />;
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryGroupBalanceCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
) : (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryGroupRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryGroupNameCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onAddCategory={onAddCategory}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibilty={onToggleVisibilty}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return (
|
||||
<CategoryGroupBudgetedCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
/>
|
||||
);
|
||||
case 'spent':
|
||||
return <ReactAriaCell />;
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryGroupBalanceCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import {
|
||||
Row as ReactAriaRow,
|
||||
Cell as ReactAriaCell,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import type {
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getCategoryRowStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { CategoryBalanceCell } from './CategoryBalanceCell';
|
||||
import { CategoryBudgetedCell } from './CategoryBudgetedCell';
|
||||
import { CategoryNameCell } from './CategoryNameCell';
|
||||
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
|
||||
type IncomeCategoryRowProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaRow<ColumnDefinition>
|
||||
> & {
|
||||
item: {
|
||||
type: 'income-category';
|
||||
id: `income-category-${string}`;
|
||||
value: CategoryEntity;
|
||||
group: CategoryGroupEntity;
|
||||
};
|
||||
onBudgetAction: (month: string, action: string, args: unknown) => void;
|
||||
onRename: (category: CategoryEntity, newName: string) => void;
|
||||
onDelete: (category: CategoryEntity) => void;
|
||||
onToggleVisibility: (category: CategoryEntity) => void;
|
||||
};
|
||||
export function IncomeCategoryRow({
|
||||
item,
|
||||
onBudgetAction,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
style,
|
||||
...props
|
||||
}: IncomeCategoryRowProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
return budgetType === 'rollover' ? (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryNameCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
categoryGroup={item.group}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return <ReactAriaCell />;
|
||||
case 'spent':
|
||||
return <ReactAriaCell />;
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryBalanceCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
) : (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryNameCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
categoryGroup={item.group}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return (
|
||||
<CategoryBudgetedCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
);
|
||||
case 'spent':
|
||||
return <ReactAriaCell />;
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryBalanceCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
);
|
||||
}
|
||||
@@ -12,14 +12,14 @@ type BalanceMovementMenuProps = {
|
||||
categoryId: string;
|
||||
month: string;
|
||||
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
||||
onSelect?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export function BalanceMovementMenu({
|
||||
categoryId,
|
||||
month,
|
||||
onBudgetAction,
|
||||
onSelect = () => {},
|
||||
onClose = () => {},
|
||||
}: BalanceMovementMenuProps) {
|
||||
const format = useFormat();
|
||||
|
||||
@@ -48,7 +48,7 @@ export function BalanceMovementMenu({
|
||||
category: categoryId,
|
||||
flag: carryover,
|
||||
});
|
||||
onSelect();
|
||||
onClose();
|
||||
}}
|
||||
onTransfer={() => setMenu('transfer')}
|
||||
onCover={() => setMenu('cover')}
|
||||
@@ -60,6 +60,7 @@ export function BalanceMovementMenu({
|
||||
categoryId={categoryId}
|
||||
initialAmount={catBalance}
|
||||
showToBeBudgeted
|
||||
onClose={onClose}
|
||||
onSubmit={(amount, toCategoryId) => {
|
||||
onBudgetAction(month, 'transfer-category', {
|
||||
amount,
|
||||
@@ -67,7 +68,6 @@ export function BalanceMovementMenu({
|
||||
to: toCategoryId,
|
||||
currencyCode: format.currency.code,
|
||||
});
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -76,6 +76,7 @@ export function BalanceMovementMenu({
|
||||
<CoverMenu
|
||||
categoryId={categoryId}
|
||||
initialAmount={catBalance}
|
||||
onClose={onClose}
|
||||
onSubmit={(amount, fromCategoryId) => {
|
||||
onBudgetAction(month, 'cover-overspending', {
|
||||
to: categoryId,
|
||||
@@ -83,7 +84,6 @@ export function BalanceMovementMenu({
|
||||
amount,
|
||||
currencyCode: format.currency.code,
|
||||
});
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ type CoverMenuProps = {
|
||||
initialAmount?: IntegerAmount | null;
|
||||
categoryId?: CategoryEntity['id'];
|
||||
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CoverMenu({
|
||||
@@ -34,6 +35,7 @@ export function CoverMenu({
|
||||
initialAmount = 0,
|
||||
categoryId,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: CoverMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -59,6 +61,7 @@ export function CoverMenu({
|
||||
if (parsedAmount && fromCategoryId) {
|
||||
onSubmit(amountToInteger(parsedAmount), fromCategoryId);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -508,7 +508,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
categoryId={category.id}
|
||||
month={month}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onSelect={() => setBalanceMenuOpen(false)}
|
||||
onClose={() => setBalanceMenuOpen(false)}
|
||||
/>
|
||||
</Popover>
|
||||
</Field>
|
||||
|
||||
@@ -22,6 +22,7 @@ type TransferMenuProps = {
|
||||
initialAmount?: IntegerAmount | null;
|
||||
showToBeBudgeted?: boolean;
|
||||
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function TransferMenu({
|
||||
@@ -29,6 +30,7 @@ export function TransferMenu({
|
||||
initialAmount = 0,
|
||||
showToBeBudgeted,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: TransferMenuProps) {
|
||||
const { grouped: originalCategoryGroups } = useCategories();
|
||||
const filteredCategoryGroups = useMemo(() => {
|
||||
@@ -52,6 +54,7 @@ export function TransferMenu({
|
||||
if (amount != null && amount > 0 && toCategoryId) {
|
||||
onSubmit(amount, toCategoryId);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -119,12 +119,12 @@ export function ToBudget({
|
||||
{menuStep === 'transfer' && (
|
||||
<TransferMenu
|
||||
initialAmount={availableValue}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onSubmit={(amount, categoryId) => {
|
||||
onBudgetAction(month, 'transfer-available', {
|
||||
amount,
|
||||
category: categoryId,
|
||||
});
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -132,13 +132,13 @@ export function ToBudget({
|
||||
<CoverMenu
|
||||
showToBeBudgeted={false}
|
||||
initialAmount={availableValue}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onSubmit={(amount, categoryId) => {
|
||||
onBudgetAction(month, 'cover-overbudgeted', {
|
||||
category: categoryId,
|
||||
amount,
|
||||
currencyCode: format.currency.code,
|
||||
});
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getCategories,
|
||||
} from '@desktop-client/budget/budgetSlice';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useCategoryMutations } from '@desktop-client/hooks/useCategoryMutations';
|
||||
import { useCategoryActions } from '@desktop-client/hooks/useCategoryActions';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
@@ -138,7 +138,7 @@ export function Budget() {
|
||||
onShowActivity,
|
||||
onReorderCategory,
|
||||
onReorderGroup,
|
||||
} = useCategoryMutations();
|
||||
} = useCategoryActions();
|
||||
|
||||
if (!initialized || !categoryGroups) {
|
||||
return null;
|
||||
|
||||
@@ -126,10 +126,6 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
return value;
|
||||
}, [value, field, subfield, dateFormat]);
|
||||
|
||||
// For ops that filter based on payeeId, those use PayeeFilter, otherwise we use GenericInput
|
||||
const isPayeeIdOp = (op: T['op']) =>
|
||||
['is', 'is not', 'one of', 'not one of'].includes(op);
|
||||
|
||||
return (
|
||||
<FocusScope>
|
||||
<View style={{ marginBottom: 10 }}>
|
||||
@@ -264,7 +260,7 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
{type !== 'boolean' && field !== 'payee' && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - fix me
|
||||
@@ -296,7 +292,7 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
/>
|
||||
)}
|
||||
|
||||
{field === 'payee' && isPayeeIdOp(op) && (
|
||||
{field === 'payee' && (
|
||||
<PayeeFilter
|
||||
// @ts-expect-error - fix me
|
||||
value={formattedValue}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { isNonProductionEnvironment } from 'loot-core/shared/environment';
|
||||
|
||||
import {
|
||||
Modal,
|
||||
@@ -18,6 +21,14 @@ type ManageRulesModalProps = Extract<
|
||||
export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const location = useLocation();
|
||||
if (isNonProductionEnvironment()) {
|
||||
if (location.pathname !== '/payees') {
|
||||
throw new Error(
|
||||
`Possibly invalid use of ManageRulesModal, add the current url \`${location.pathname}\` to the allowlist if you're confident the modal can never appear on top of the \`/rules\` page.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal name="manage-rules" isLoading={loading}>
|
||||
|
||||
@@ -38,6 +38,10 @@ const currentIntervalOptions = [
|
||||
description: t('Year to date'),
|
||||
disableInclude: true,
|
||||
},
|
||||
{
|
||||
description: t('Last month'),
|
||||
disableInclude: true,
|
||||
},
|
||||
{
|
||||
description: t('Last year'),
|
||||
disableInclude: true,
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
|
||||
import { FormulaActionEditor } from './FormulaActionEditor';
|
||||
|
||||
import { PayeeComboBox } from '@desktop-client/components/autocomplete/PayeeComboBox';
|
||||
import { StatusBadge } from '@desktop-client/components/schedules/StatusBadge';
|
||||
import { SimpleTransactionsTable } from '@desktop-client/components/transactions/SimpleTransactionsTable';
|
||||
import { BetweenAmountInput } from '@desktop-client/components/util/AmountInput';
|
||||
@@ -319,6 +320,13 @@ function ConditionEditor({
|
||||
onChange={v => onChange('value', v)}
|
||||
/>
|
||||
);
|
||||
} else if (field === 'payee') {
|
||||
valueEditor = (
|
||||
<PayeeComboBox
|
||||
selectedKey={value}
|
||||
onSelectionChange={id => onChange('value', id)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
valueEditor = (
|
||||
<GenericInput
|
||||
|
||||
@@ -205,12 +205,6 @@ export function ExperimentalFeatures() {
|
||||
<FeatureToggle flag="forceReload">
|
||||
<Trans>Force reload app button</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle
|
||||
flag="budgetTableV2"
|
||||
feedbackLink="https://github.com/actualbudget/actual/pull/CHANGEME"
|
||||
>
|
||||
<Trans>Rewrite of desktop budget table</Trans>
|
||||
</FeatureToggle>
|
||||
{showServerPrefs && (
|
||||
<ServerFeatureToggle
|
||||
prefName="flags.plugins"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { isElectron } from 'loot-core/shared/environment';
|
||||
|
||||
import { Setting } from './UI';
|
||||
|
||||
@@ -88,3 +89,46 @@ export function ResetSync() {
|
||||
</Setting>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForceReload() {
|
||||
const [reloading, setReloading] = useState(false);
|
||||
|
||||
async function onForceReload() {
|
||||
setReloading(true);
|
||||
try {
|
||||
if (!isElectron()) {
|
||||
const registration =
|
||||
await window.navigator.serviceWorker.getRegistration('/');
|
||||
if (registration) {
|
||||
await registration.update();
|
||||
if (registration.waiting) {
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Setting
|
||||
primaryAction={
|
||||
<ButtonWithLoading isLoading={reloading} onPress={onForceReload}>
|
||||
<Trans>Force reload app</Trans>
|
||||
</ButtonWithLoading>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Trans>
|
||||
<strong>Force reload app</strong> will clear the cached version of the
|
||||
app and load a fresh one. This is useful if you're experiencing
|
||||
issues with the app after an update or if cached files are causing
|
||||
problems. The app will reload automatically after clearing the cache.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Setting>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ExportBudget } from './Export';
|
||||
import { FormatSettings } from './Format';
|
||||
import { LanguageSettings } from './LanguageSettings';
|
||||
import { RepairTransactions } from './RepairTransactions';
|
||||
import { ResetCache, ResetSync } from './Reset';
|
||||
import { ForceReload, ResetCache, ResetSync } from './Reset';
|
||||
import { ThemeSettings } from './Themes';
|
||||
import { AdvancedToggle, Setting } from './UI';
|
||||
|
||||
@@ -176,6 +176,7 @@ export function Settings() {
|
||||
const [budgetName] = useMetadataPref('budgetName');
|
||||
const dispatch = useDispatch();
|
||||
const isCurrencyExperimentalEnabled = useFeatureFlag('currency');
|
||||
const isForceReloadEnabled = useFeatureFlag('forceReload');
|
||||
const [_, setDefaultCurrencyCodePref] = useSyncedPref('defaultCurrencyCode');
|
||||
|
||||
const onCloseBudget = () => {
|
||||
@@ -252,6 +253,7 @@ export function Settings() {
|
||||
<ExportBudget />
|
||||
<AdvancedToggle>
|
||||
<AdvancedAbout />
|
||||
{isForceReloadEnabled && <ForceReload />}
|
||||
<ResetCache />
|
||||
<ResetSync />
|
||||
<RepairTransactions />
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
Fragment,
|
||||
} from 'react';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
@@ -44,15 +43,10 @@ export function CellValue<
|
||||
const { fullSheetName } = useSheetName(binding);
|
||||
const sheetValue = useSheetValue(binding);
|
||||
|
||||
// Re-render when these value changes.
|
||||
const key = `${fullSheetName}|${sheetValue}`;
|
||||
return typeof children === 'function' ? (
|
||||
<Fragment key={key}>
|
||||
{children({ type, name: fullSheetName, value: sheetValue })}
|
||||
</Fragment>
|
||||
<>{children({ type, name: fullSheetName, value: sheetValue })}</>
|
||||
) : (
|
||||
<CellValueText
|
||||
key={key}
|
||||
type={type}
|
||||
name={fullSheetName}
|
||||
value={sheetValue}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import {
|
||||
type CategoryEntity,
|
||||
type CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useCategories } from './useCategories';
|
||||
import { useNavigate } from './useNavigate';
|
||||
|
||||
import {
|
||||
createCategory,
|
||||
createCategoryGroup,
|
||||
deleteCategory,
|
||||
deleteCategoryGroup,
|
||||
moveCategory,
|
||||
moveCategoryGroup,
|
||||
updateCategory,
|
||||
updateCategoryGroup,
|
||||
} from '@desktop-client/budget/budgetSlice';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
export function useCategoryMutations() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
|
||||
const categoryNameAlreadyExistsNotification = (
|
||||
name: CategoryEntity['name'],
|
||||
) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t(
|
||||
'Category "{{name}}" already exists in group (it may be hidden)',
|
||||
{ name },
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onSaveCategory = async (category: CategoryEntity) => {
|
||||
const { grouped: categoryGroups = [] } = await send('get-categories');
|
||||
|
||||
const group = categoryGroups.find(g => g.id === category.group);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCategories = group.categories ?? [];
|
||||
|
||||
const exists =
|
||||
groupCategories
|
||||
.filter(c => c.name.toUpperCase() === category.name.toUpperCase())
|
||||
.filter(c => (category.id === 'new' ? true : c.id !== category.id))
|
||||
.length > 0;
|
||||
|
||||
if (exists) {
|
||||
categoryNameAlreadyExistsNotification(category.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (category.id === 'new') {
|
||||
dispatch(
|
||||
createCategory({
|
||||
name: category.name,
|
||||
groupId: category.group,
|
||||
isIncome: !!category.is_income,
|
||||
isHidden: !!category.hidden,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(updateCategory({ category }));
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteCategory = async (id: CategoryEntity['id']) => {
|
||||
const mustTransfer = await send('must-category-transfer', { id });
|
||||
|
||||
if (mustTransfer) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-category-delete',
|
||||
options: {
|
||||
category: id,
|
||||
onDelete: transferCategory => {
|
||||
if (id !== transferCategory) {
|
||||
dispatch(
|
||||
deleteCategory({ id, transferId: transferCategory }),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(deleteCategory({ id }));
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveGroup = (group: CategoryGroupEntity) => {
|
||||
if (group.id === 'new') {
|
||||
dispatch(createCategoryGroup({ name: group.name }));
|
||||
} else {
|
||||
dispatch(updateCategoryGroup({ group }));
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteGroup = async (id: CategoryGroupEntity['id']) => {
|
||||
const group = categoryGroups.find(g => g.id === id);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCategories = group.categories ?? [];
|
||||
|
||||
let mustTransfer = false;
|
||||
for (const category of groupCategories) {
|
||||
if (await send('must-category-transfer', { id: category.id })) {
|
||||
mustTransfer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mustTransfer) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-category-delete',
|
||||
options: {
|
||||
group: id,
|
||||
onDelete: transferCategory => {
|
||||
dispatch(
|
||||
deleteCategoryGroup({ id, transferId: transferCategory }),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(deleteCategoryGroup({ id }));
|
||||
}
|
||||
};
|
||||
|
||||
const onShowActivity = (categoryId: CategoryEntity['id'], month: string) => {
|
||||
const filterConditions = [
|
||||
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
|
||||
{
|
||||
field: 'date',
|
||||
op: 'is',
|
||||
value: month,
|
||||
options: { month: true },
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
filterConditions,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onReorderCategory = async (sortInfo: {
|
||||
id: CategoryEntity['id'];
|
||||
groupId?: CategoryGroupEntity['id'];
|
||||
targetId: CategoryEntity['id'] | null;
|
||||
}) => {
|
||||
const { grouped: categoryGroups = [], list: categories = [] } =
|
||||
await send('get-categories');
|
||||
|
||||
const moveCandidate = categories.find(c => c.id === sortInfo.id);
|
||||
const group = categoryGroups.find(g => g.id === sortInfo.groupId);
|
||||
|
||||
if (!moveCandidate || !group) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCategories = group.categories ?? [];
|
||||
|
||||
const exists =
|
||||
groupCategories
|
||||
.filter(c => c.name.toUpperCase() === moveCandidate.name.toUpperCase())
|
||||
.filter(c => c.id !== moveCandidate.id).length > 0;
|
||||
|
||||
if (exists) {
|
||||
categoryNameAlreadyExistsNotification(moveCandidate.name);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: moveCandidate.id,
|
||||
groupId: group.id,
|
||||
targetId: sortInfo.targetId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onReorderGroup = async (sortInfo: {
|
||||
id: CategoryGroupEntity['id'];
|
||||
targetId: CategoryGroupEntity['id'] | null;
|
||||
}) => {
|
||||
dispatch(
|
||||
moveCategoryGroup({ id: sortInfo.id, targetId: sortInfo.targetId }),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
onSaveCategory,
|
||||
onDeleteCategory,
|
||||
onSaveGroup,
|
||||
onDeleteGroup,
|
||||
onShowActivity,
|
||||
onReorderCategory,
|
||||
onReorderGroup,
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
formulaMode: false,
|
||||
currency: false,
|
||||
crossoverReport: false,
|
||||
plugins: false,
|
||||
forceReload: false,
|
||||
budgetTableV2: false,
|
||||
};
|
||||
|
||||
export function useFeatureFlag(name: FeatureFlag): boolean {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: Reflecting on Your 2025 Finances
|
||||
description: We created a small app to help you look back at your financial year — Actual Budget Wrapped 2025.
|
||||
date: 2026-01-10T10:00
|
||||
slug: actual-budget-wrapped-2025
|
||||
tags: [announcement]
|
||||
hide_table_of_contents: false
|
||||
authors: MatissJanis
|
||||
---
|
||||
|
||||
As we step into 2026, it's natural to look back at the year that just passed. While many apps send you a "year in review" at the end of December, we thought it would be fun to create something special for Actual Budget users — a way to reflect on your financial journey throughout 2025.
|
||||
|
||||
<!--truncate-->
|
||||
|
||||
## Introducing Actual Budget Wrapped 2025
|
||||
|
||||
We've built a small web app that helps you visualize your financial story from the past year. [Actual Budget Wrapped 2025](https://wrapped.actualbudget.org) takes your Actual Budget data and transforms it into a personalized financial recap.
|
||||
|
||||
Whether you want to see your biggest spending categories, track your income trends, or simply marvel at how many transactions you've managed — this tool gives you a different perspective on your financial data. Sometimes it's helpful to step back and see the bigger picture of where your money went throughout the year.
|
||||
|
||||
The app is designed to be simple and privacy-focused. Your data stays in your browser and is processed locally. We don't collect or store any of your financial information — it's just between you and your Actual Budget file.
|
||||
|
||||
## Why Reflect?
|
||||
|
||||
Looking back at your finances can be eye-opening. Maybe you'll discover patterns you weren't aware of, or perhaps you'll be pleasantly surprised by how much progress you've made toward your goals. Maybe you'll spot areas where you can improve, or celebrate wins you've forgotten about.
|
||||
|
||||
Personal finance isn't just about numbers on a screen — it's about the choices we make, the goals we set, and the progress we track along the way. Sometimes taking a step back helps us appreciate how far we've come.
|
||||
|
||||
So head over to [wrapped.actualbudget.org](https://wrapped.actualbudget.org) and take a few minutes to see your 2025 financial story. We hope it brings you some insights, or at the very least, a moment of reflection as you plan for the year ahead.
|
||||
|
||||
Here's to making 2026 another great year for your finances!
|
||||
@@ -20,7 +20,7 @@ type ScheduleTemplateTarget = {
|
||||
target: number;
|
||||
next_date_string: string;
|
||||
target_interval: number;
|
||||
target_frequency: string | undefined;
|
||||
target_frequency: string;
|
||||
num_months: number;
|
||||
completed: number;
|
||||
full: boolean;
|
||||
@@ -238,8 +238,6 @@ function getSinkingBaseContributionTotal(t: ScheduleTemplateTarget[]) {
|
||||
monthlyAmount = schedule.target / intervalMonths;
|
||||
break;
|
||||
default:
|
||||
// default to same math as monthly for now for non-reoccuring
|
||||
monthlyAmount = schedule.target / schedule.target_interval;
|
||||
break;
|
||||
}
|
||||
total += monthlyAmount;
|
||||
@@ -277,21 +275,17 @@ export async function runSchedule(
|
||||
|
||||
const isPayMonthOf = c =>
|
||||
c.full ||
|
||||
((c.target_frequency === 'monthly' || !c.target_frequency) &&
|
||||
(c.target_frequency === 'monthly' &&
|
||||
c.target_interval === 1 &&
|
||||
c.num_months === 0) ||
|
||||
(c.target_frequency === 'weekly' && c.target_interval <= 4) ||
|
||||
(c.target_frequency === 'daily' && c.target_interval <= 31) ||
|
||||
isReflectBudget();
|
||||
|
||||
const isSubMonthly = c =>
|
||||
c.target_frequency === 'weekly' || c.target_frequency === 'daily';
|
||||
|
||||
const t_payMonthOf = t.t.filter(isPayMonthOf);
|
||||
const t_sinking = t.t
|
||||
.filter(c => !isPayMonthOf(c))
|
||||
.sort((a, b) => a.next_date_string.localeCompare(b.next_date_string));
|
||||
const numSubMonthly = t.t.filter(isSubMonthly).length;
|
||||
const totalPayMonthOf = getPayMonthOfTotal(t_payMonthOf);
|
||||
const totalSinking = getSinkingTotal(t_sinking);
|
||||
const totalSinkingBaseContribution =
|
||||
@@ -309,8 +303,7 @@ export async function runSchedule(
|
||||
balance >= totalSinking + totalPayMonthOf ||
|
||||
(lastMonthGoal < totalSinking + totalPayMonthOf &&
|
||||
lastMonthGoal !== 0 &&
|
||||
balance >= lastMonthGoal &&
|
||||
numSubMonthly > 0)
|
||||
balance >= lastMonthGoal)
|
||||
) {
|
||||
to_budget += Math.round(totalPayMonthOf + totalSinkingBaseContribution);
|
||||
} else {
|
||||
|
||||
@@ -4,3 +4,11 @@ export function getNormalisedString(value: string) {
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
|
||||
export function normalisedEquals(a: string, b: string) {
|
||||
return getNormalisedString(a) === getNormalisedString(b);
|
||||
}
|
||||
|
||||
export function normalisedIncludes(a: string, b: string) {
|
||||
return getNormalisedString(a).includes(getNormalisedString(b));
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@ export type FeatureFlag =
|
||||
| 'formulaMode'
|
||||
| 'currency'
|
||||
| 'crossoverReport'
|
||||
| 'plugins'
|
||||
| 'forceReload'
|
||||
| 'budgetTableV2';
|
||||
| 'forceReload';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
[Experimental] Rewrite of desktop budget table
|
||||
6
upcoming-release-notes/5875.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Implement PayeeAutocomplete2 based on react-aria-component's ComboBox
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [csenel]
|
||||
---
|
||||
|
||||
Enable include current month option for last month
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [RMcGhee]
|
||||
---
|
||||
|
||||
Fix payee filter functionality to improve transaction filtering in the application.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [dependabot]
|
||||
---
|
||||
|
||||
Bump react-router version from 7.9.6 to 7.12.0 for improved functionality and performance.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Fix schedule template regressions where categories are being underbudgeted and to improve functionality and user experience.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [RMcGhee]
|
||||
---
|
||||
|
||||
Remove url check that throws error in development
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Remove the force reload feature from the application settings.
|
||||
10
yarn.lock
@@ -195,7 +195,7 @@ __metadata:
|
||||
react-markdown: "npm:^10.1.0"
|
||||
react-modal: "npm:3.16.3"
|
||||
react-redux: "npm:^9.2.0"
|
||||
react-router: "npm:7.12.0"
|
||||
react-router: "npm:7.9.6"
|
||||
react-simple-pull-to-refresh: "npm:^1.3.3"
|
||||
react-spring: "npm:10.0.0"
|
||||
react-swipeable: "npm:^7.0.2"
|
||||
@@ -23299,9 +23299,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-router@npm:7.12.0":
|
||||
version: 7.12.0
|
||||
resolution: "react-router@npm:7.12.0"
|
||||
"react-router@npm:7.9.6":
|
||||
version: 7.9.6
|
||||
resolution: "react-router@npm:7.9.6"
|
||||
dependencies:
|
||||
cookie: "npm:^1.0.1"
|
||||
set-cookie-parser: "npm:^2.6.0"
|
||||
@@ -23311,7 +23311,7 @@ __metadata:
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/578324f792721200bd57a220c7931af692613943051c9bb0c6303613849ec9a2c2365a3a6afe1b3976c13edc8f71616bb9cfdb13c0ac501f239ad11a6884e3f8
|
||||
checksum: 10/f34714b3701caf689c306631f5326a9fdab585799021c234aa3eee75bed6bfcea9250f0867e984e4e3c43c77d947c41bd47b70c0601d76c4290e03247fb7ac23
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||