mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
27 Commits
copilot/fi
...
budget-tab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ed0ab628 | ||
|
|
d799ede866 | ||
|
|
02cbd7729b | ||
|
|
806d54720d | ||
|
|
5a8b3a0acc | ||
|
|
1cabe8ab6c | ||
|
|
f378d75727 | ||
|
|
1b02460456 | ||
|
|
1db3a43c56 | ||
|
|
eb5b5231dc | ||
|
|
475c4ef521 | ||
|
|
04301e9bc8 | ||
|
|
d6f313b06e | ||
|
|
165b6d3b17 | ||
|
|
7b7d9d2ddb | ||
|
|
3db6af520c | ||
|
|
a24d180002 | ||
|
|
4f6d5b5544 | ||
|
|
8bfb3bbf1d | ||
|
|
72124c772f | ||
|
|
3f827b8fea | ||
|
|
f8391c6e45 | ||
|
|
58fa711f4a | ||
|
|
29c52a830c | ||
|
|
e63e68e764 | ||
|
|
e8917275fa | ||
|
|
cad15ed4f9 |
@@ -6,6 +6,7 @@ import React, {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
@@ -49,6 +50,7 @@ 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']>
|
||||
>(
|
||||
@@ -87,7 +89,9 @@ export function NotesButton({
|
||||
...(isOpen && { color: theme.buttonNormalText }),
|
||||
'&:hover': { opacity: 1 },
|
||||
}),
|
||||
!hasNotes && !isOpen && !showPlaceholder ? 'hover-visible' : '',
|
||||
!hasNotes && !isOpen && !isFocusVisible && !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': {
|
||||
|
||||
1185
packages/desktop-client/src/components/budget/BudgetCategoriesV2.tsx
Normal file
1185
packages/desktop-client/src/components/budget/BudgetCategoriesV2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -24,6 +25,7 @@ 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';
|
||||
|
||||
@@ -82,6 +84,7 @@ 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);
|
||||
@@ -205,15 +208,11 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
setShowHiddenCategoriesPef(!showHiddenCategories);
|
||||
};
|
||||
|
||||
const toggleHiddenCategories = () => {
|
||||
onToggleHiddenCategories();
|
||||
};
|
||||
|
||||
const expandAllCategories = () => {
|
||||
const onExpandAllCategories = () => {
|
||||
onCollapse([]);
|
||||
};
|
||||
|
||||
const collapseAllCategories = () => {
|
||||
const onCollapseAllCategories = () => {
|
||||
onCollapse(categoryGroups.map(g => g.id));
|
||||
};
|
||||
|
||||
@@ -262,45 +261,62 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
monthBounds={monthBounds}
|
||||
type={type}
|
||||
>
|
||||
<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}
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
{!budgetTableV2Enabled && (
|
||||
<>
|
||||
<BudgetTotals
|
||||
toggleHiddenCategories={onToggleHiddenCategories}
|
||||
expandAllCategories={onExpandAllCategories}
|
||||
collapseAllCategories={onCollapseAllCategories}
|
||||
/>
|
||||
</SchedulesProvider>
|
||||
</View>
|
||||
</View>
|
||||
<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
|
||||
onBudgetAction={onBudgetAction}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
onToggleHiddenCategories={onToggleHiddenCategories}
|
||||
onExpandAllCategories={onExpandAllCategories}
|
||||
onCollapseAllCategories={onCollapseAllCategories}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</SchedulesProvider>
|
||||
</MonthsProvider>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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,6 +11,7 @@ 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) {
|
||||
@@ -78,8 +79,12 @@ 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(
|
||||
'left',
|
||||
budgetTableV2Enabled ? 'alt+left' : 'left',
|
||||
() => {
|
||||
_onMonthSelect(monthUtils.prevMonth(startMonth));
|
||||
},
|
||||
@@ -90,7 +95,7 @@ const DynamicBudgetTable = ({
|
||||
[_onMonthSelect, startMonth],
|
||||
);
|
||||
useHotkeys(
|
||||
'right',
|
||||
budgetTableV2Enabled ? 'alt+right' : 'right',
|
||||
() => {
|
||||
_onMonthSelect(monthUtils.nextMonth(startMonth));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
onClose?: () => void;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
export function BalanceMovementMenu({
|
||||
categoryId,
|
||||
month,
|
||||
onBudgetAction,
|
||||
onClose = () => {},
|
||||
onSelect = () => {},
|
||||
}: BalanceMovementMenuProps) {
|
||||
const format = useFormat();
|
||||
|
||||
@@ -48,7 +48,7 @@ export function BalanceMovementMenu({
|
||||
category: categoryId,
|
||||
flag: carryover,
|
||||
});
|
||||
onClose();
|
||||
onSelect();
|
||||
}}
|
||||
onTransfer={() => setMenu('transfer')}
|
||||
onCover={() => setMenu('cover')}
|
||||
@@ -60,7 +60,6 @@ export function BalanceMovementMenu({
|
||||
categoryId={categoryId}
|
||||
initialAmount={catBalance}
|
||||
showToBeBudgeted
|
||||
onClose={onClose}
|
||||
onSubmit={(amount, toCategoryId) => {
|
||||
onBudgetAction(month, 'transfer-category', {
|
||||
amount,
|
||||
@@ -68,6 +67,7 @@ export function BalanceMovementMenu({
|
||||
to: toCategoryId,
|
||||
currencyCode: format.currency.code,
|
||||
});
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -76,7 +76,6 @@ export function BalanceMovementMenu({
|
||||
<CoverMenu
|
||||
categoryId={categoryId}
|
||||
initialAmount={catBalance}
|
||||
onClose={onClose}
|
||||
onSubmit={(amount, fromCategoryId) => {
|
||||
onBudgetAction(month, 'cover-overspending', {
|
||||
to: categoryId,
|
||||
@@ -84,6 +83,7 @@ export function BalanceMovementMenu({
|
||||
amount,
|
||||
currencyCode: format.currency.code,
|
||||
});
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -27,7 +27,6 @@ type CoverMenuProps = {
|
||||
initialAmount?: IntegerAmount | null;
|
||||
categoryId?: CategoryEntity['id'];
|
||||
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CoverMenu({
|
||||
@@ -35,7 +34,6 @@ export function CoverMenu({
|
||||
initialAmount = 0,
|
||||
categoryId,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: CoverMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -61,7 +59,6 @@ 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}
|
||||
onClose={() => setBalanceMenuOpen(false)}
|
||||
onSelect={() => setBalanceMenuOpen(false)}
|
||||
/>
|
||||
</Popover>
|
||||
</Field>
|
||||
|
||||
@@ -22,7 +22,6 @@ type TransferMenuProps = {
|
||||
initialAmount?: IntegerAmount | null;
|
||||
showToBeBudgeted?: boolean;
|
||||
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function TransferMenu({
|
||||
@@ -30,7 +29,6 @@ export function TransferMenu({
|
||||
initialAmount = 0,
|
||||
showToBeBudgeted,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: TransferMenuProps) {
|
||||
const { grouped: originalCategoryGroups } = useCategories();
|
||||
const filteredCategoryGroups = useMemo(() => {
|
||||
@@ -54,7 +52,6 @@ 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 { useCategoryActions } from '@desktop-client/hooks/useCategoryActions';
|
||||
import { useCategoryMutations } from '@desktop-client/hooks/useCategoryMutations';
|
||||
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,
|
||||
} = useCategoryActions();
|
||||
} = useCategoryMutations();
|
||||
|
||||
if (!initialized || !categoryGroups) {
|
||||
return null;
|
||||
|
||||
@@ -202,6 +202,15 @@ export function ExperimentalFeatures() {
|
||||
>
|
||||
<Trans>Crossover Report</Trans>
|
||||
</FeatureToggle>
|
||||
<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"
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
Fragment,
|
||||
} from 'react';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
@@ -43,10 +44,15 @@ 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' ? (
|
||||
<>{children({ type, name: fullSheetName, value: sheetValue })}</>
|
||||
<Fragment key={key}>
|
||||
{children({ type, name: fullSheetName, value: sheetValue })}
|
||||
</Fragment>
|
||||
) : (
|
||||
<CellValueText
|
||||
key={key}
|
||||
type={type}
|
||||
name={fullSheetName}
|
||||
value={sheetValue}
|
||||
|
||||
228
packages/desktop-client/src/hooks/useCategoryMutations.ts
Normal file
228
packages/desktop-client/src/hooks/useCategoryMutations.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
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,6 +9,9 @@ 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 {
|
||||
|
||||
@@ -4,7 +4,10 @@ export type FeatureFlag =
|
||||
| 'actionTemplating'
|
||||
| 'formulaMode'
|
||||
| 'currency'
|
||||
| 'crossoverReport';
|
||||
| 'crossoverReport'
|
||||
| 'plugins'
|
||||
| 'forceReload'
|
||||
| 'budgetTableV2';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
6
upcoming-release-notes/4573.md
Normal file
6
upcoming-release-notes/4573.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
[Experimental] Rewrite of desktop budget table
|
||||
Reference in New Issue
Block a user