mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Compare commits
27 Commits
js-proxy
...
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,
|
type CSSProperties,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { useFocusVisible } from 'react-aria';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button } from '@actual-app/components/button';
|
import { Button } from '@actual-app/components/button';
|
||||||
@@ -49,6 +50,7 @@ export function NotesButton({
|
|||||||
const [tempNotes, setTempNotes] = useState<string>(note);
|
const [tempNotes, setTempNotes] = useState<string>(note);
|
||||||
useEffect(() => setTempNotes(note), [note, id]);
|
useEffect(() => setTempNotes(note), [note, id]);
|
||||||
|
|
||||||
|
const { isFocusVisible } = useFocusVisible();
|
||||||
const onOpenChange = useCallback<
|
const onOpenChange = useCallback<
|
||||||
NonNullable<ComponentProps<typeof Popover>['onOpenChange']>
|
NonNullable<ComponentProps<typeof Popover>['onOpenChange']>
|
||||||
>(
|
>(
|
||||||
@@ -87,7 +89,9 @@ export function NotesButton({
|
|||||||
...(isOpen && { color: theme.buttonNormalText }),
|
...(isOpen && { color: theme.buttonNormalText }),
|
||||||
'&:hover': { opacity: 1 },
|
'&:hover': { opacity: 1 },
|
||||||
}),
|
}),
|
||||||
!hasNotes && !isOpen && !showPlaceholder ? 'hover-visible' : '',
|
!hasNotes && !isOpen && !isFocusVisible && !showPlaceholder
|
||||||
|
? 'hover-visible'
|
||||||
|
: '',
|
||||||
)}
|
)}
|
||||||
data-placeholder={showPlaceholder}
|
data-placeholder={showPlaceholder}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ function PrivacyOverlay({ children, ...props }) {
|
|||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
' > div:first-child': {
|
'> div:first-child': {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
' > div:nth-child(2)': {
|
'> div:nth-child(2)': {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
},
|
},
|
||||||
'&:hover': {
|
'&: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';
|
} from 'loot-core/types/models';
|
||||||
|
|
||||||
import { BudgetCategories } from './BudgetCategories';
|
import { BudgetCategories } from './BudgetCategories';
|
||||||
|
import { BudgetCategories as BudgetCategoriesV2 } from './BudgetCategoriesV2';
|
||||||
import { BudgetSummaries } from './BudgetSummaries';
|
import { BudgetSummaries } from './BudgetSummaries';
|
||||||
import { BudgetTotals } from './BudgetTotals';
|
import { BudgetTotals } from './BudgetTotals';
|
||||||
import { type MonthBounds, MonthsProvider } from './MonthsContext';
|
import { type MonthBounds, MonthsProvider } from './MonthsContext';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
import { type DropPosition } from '@desktop-client/components/sort';
|
import { type DropPosition } from '@desktop-client/components/sort';
|
||||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||||
|
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
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>(
|
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const budgetTableV2Enabled = useFeatureFlag('budgetTableV2');
|
||||||
|
|
||||||
const onEditMonth = (id: string, month: string) => {
|
const onEditMonth = (id: string, month: string) => {
|
||||||
setEditing(id ? { id, cell: month } : null);
|
setEditing(id ? { id, cell: month } : null);
|
||||||
@@ -205,15 +208,11 @@ export function BudgetTable(props: BudgetTableProps) {
|
|||||||
setShowHiddenCategoriesPef(!showHiddenCategories);
|
setShowHiddenCategoriesPef(!showHiddenCategories);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleHiddenCategories = () => {
|
const onExpandAllCategories = () => {
|
||||||
onToggleHiddenCategories();
|
|
||||||
};
|
|
||||||
|
|
||||||
const expandAllCategories = () => {
|
|
||||||
onCollapse([]);
|
onCollapse([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const collapseAllCategories = () => {
|
const onCollapseAllCategories = () => {
|
||||||
onCollapse(categoryGroups.map(g => g.id));
|
onCollapse(categoryGroups.map(g => g.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,45 +261,62 @@ export function BudgetTable(props: BudgetTableProps) {
|
|||||||
monthBounds={monthBounds}
|
monthBounds={monthBounds}
|
||||||
type={type}
|
type={type}
|
||||||
>
|
>
|
||||||
<BudgetTotals
|
<SchedulesProvider query={schedulesQuery}>
|
||||||
toggleHiddenCategories={toggleHiddenCategories}
|
{!budgetTableV2Enabled && (
|
||||||
expandAllCategories={expandAllCategories}
|
<>
|
||||||
collapseAllCategories={collapseAllCategories}
|
<BudgetTotals
|
||||||
/>
|
toggleHiddenCategories={onToggleHiddenCategories}
|
||||||
<View
|
expandAllCategories={onExpandAllCategories}
|
||||||
style={{
|
collapseAllCategories={onCollapseAllCategories}
|
||||||
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>
|
<View
|
||||||
</View>
|
style={{
|
||||||
</View>
|
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>
|
</MonthsProvider>
|
||||||
</View>
|
</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 { BudgetPageHeader } from './BudgetPageHeader';
|
||||||
import { BudgetTable } from './BudgetTable';
|
import { BudgetTable } from './BudgetTable';
|
||||||
|
|
||||||
|
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||||
|
|
||||||
function getNumPossibleMonths(width: number, categoryWidth: number) {
|
function getNumPossibleMonths(width: number, categoryWidth: number) {
|
||||||
@@ -78,8 +79,12 @@ const DynamicBudgetTable = ({
|
|||||||
onMonthSelect(getValidMonth(month), numMonths);
|
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(
|
useHotkeys(
|
||||||
'left',
|
budgetTableV2Enabled ? 'alt+left' : 'left',
|
||||||
() => {
|
() => {
|
||||||
_onMonthSelect(monthUtils.prevMonth(startMonth));
|
_onMonthSelect(monthUtils.prevMonth(startMonth));
|
||||||
},
|
},
|
||||||
@@ -90,7 +95,7 @@ const DynamicBudgetTable = ({
|
|||||||
[_onMonthSelect, startMonth],
|
[_onMonthSelect, startMonth],
|
||||||
);
|
);
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'right',
|
budgetTableV2Enabled ? 'alt+right' : 'right',
|
||||||
() => {
|
() => {
|
||||||
_onMonthSelect(monthUtils.nextMonth(startMonth));
|
_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;
|
categoryId: string;
|
||||||
month: string;
|
month: string;
|
||||||
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
||||||
onClose?: () => void;
|
onSelect?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BalanceMovementMenu({
|
export function BalanceMovementMenu({
|
||||||
categoryId,
|
categoryId,
|
||||||
month,
|
month,
|
||||||
onBudgetAction,
|
onBudgetAction,
|
||||||
onClose = () => {},
|
onSelect = () => {},
|
||||||
}: BalanceMovementMenuProps) {
|
}: BalanceMovementMenuProps) {
|
||||||
const format = useFormat();
|
const format = useFormat();
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export function BalanceMovementMenu({
|
|||||||
category: categoryId,
|
category: categoryId,
|
||||||
flag: carryover,
|
flag: carryover,
|
||||||
});
|
});
|
||||||
onClose();
|
onSelect();
|
||||||
}}
|
}}
|
||||||
onTransfer={() => setMenu('transfer')}
|
onTransfer={() => setMenu('transfer')}
|
||||||
onCover={() => setMenu('cover')}
|
onCover={() => setMenu('cover')}
|
||||||
@@ -60,7 +60,6 @@ export function BalanceMovementMenu({
|
|||||||
categoryId={categoryId}
|
categoryId={categoryId}
|
||||||
initialAmount={catBalance}
|
initialAmount={catBalance}
|
||||||
showToBeBudgeted
|
showToBeBudgeted
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={(amount, toCategoryId) => {
|
onSubmit={(amount, toCategoryId) => {
|
||||||
onBudgetAction(month, 'transfer-category', {
|
onBudgetAction(month, 'transfer-category', {
|
||||||
amount,
|
amount,
|
||||||
@@ -68,6 +67,7 @@ export function BalanceMovementMenu({
|
|||||||
to: toCategoryId,
|
to: toCategoryId,
|
||||||
currencyCode: format.currency.code,
|
currencyCode: format.currency.code,
|
||||||
});
|
});
|
||||||
|
onSelect();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -76,7 +76,6 @@ export function BalanceMovementMenu({
|
|||||||
<CoverMenu
|
<CoverMenu
|
||||||
categoryId={categoryId}
|
categoryId={categoryId}
|
||||||
initialAmount={catBalance}
|
initialAmount={catBalance}
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={(amount, fromCategoryId) => {
|
onSubmit={(amount, fromCategoryId) => {
|
||||||
onBudgetAction(month, 'cover-overspending', {
|
onBudgetAction(month, 'cover-overspending', {
|
||||||
to: categoryId,
|
to: categoryId,
|
||||||
@@ -84,6 +83,7 @@ export function BalanceMovementMenu({
|
|||||||
amount,
|
amount,
|
||||||
currencyCode: format.currency.code,
|
currencyCode: format.currency.code,
|
||||||
});
|
});
|
||||||
|
onSelect();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ type CoverMenuProps = {
|
|||||||
initialAmount?: IntegerAmount | null;
|
initialAmount?: IntegerAmount | null;
|
||||||
categoryId?: CategoryEntity['id'];
|
categoryId?: CategoryEntity['id'];
|
||||||
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
||||||
onClose: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CoverMenu({
|
export function CoverMenu({
|
||||||
@@ -35,7 +34,6 @@ export function CoverMenu({
|
|||||||
initialAmount = 0,
|
initialAmount = 0,
|
||||||
categoryId,
|
categoryId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
|
||||||
}: CoverMenuProps) {
|
}: CoverMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -61,7 +59,6 @@ export function CoverMenu({
|
|||||||
if (parsedAmount && fromCategoryId) {
|
if (parsedAmount && fromCategoryId) {
|
||||||
onSubmit(amountToInteger(parsedAmount), fromCategoryId);
|
onSubmit(amountToInteger(parsedAmount), fromCategoryId);
|
||||||
}
|
}
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -508,7 +508,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
|||||||
categoryId={category.id}
|
categoryId={category.id}
|
||||||
month={month}
|
month={month}
|
||||||
onBudgetAction={onBudgetAction}
|
onBudgetAction={onBudgetAction}
|
||||||
onClose={() => setBalanceMenuOpen(false)}
|
onSelect={() => setBalanceMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ type TransferMenuProps = {
|
|||||||
initialAmount?: IntegerAmount | null;
|
initialAmount?: IntegerAmount | null;
|
||||||
showToBeBudgeted?: boolean;
|
showToBeBudgeted?: boolean;
|
||||||
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
||||||
onClose: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TransferMenu({
|
export function TransferMenu({
|
||||||
@@ -30,7 +29,6 @@ export function TransferMenu({
|
|||||||
initialAmount = 0,
|
initialAmount = 0,
|
||||||
showToBeBudgeted,
|
showToBeBudgeted,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
|
||||||
}: TransferMenuProps) {
|
}: TransferMenuProps) {
|
||||||
const { grouped: originalCategoryGroups } = useCategories();
|
const { grouped: originalCategoryGroups } = useCategories();
|
||||||
const filteredCategoryGroups = useMemo(() => {
|
const filteredCategoryGroups = useMemo(() => {
|
||||||
@@ -54,7 +52,6 @@ export function TransferMenu({
|
|||||||
if (amount != null && amount > 0 && toCategoryId) {
|
if (amount != null && amount > 0 && toCategoryId) {
|
||||||
onSubmit(amount, toCategoryId);
|
onSubmit(amount, toCategoryId);
|
||||||
}
|
}
|
||||||
onClose();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -119,12 +119,12 @@ export function ToBudget({
|
|||||||
{menuStep === 'transfer' && (
|
{menuStep === 'transfer' && (
|
||||||
<TransferMenu
|
<TransferMenu
|
||||||
initialAmount={availableValue}
|
initialAmount={availableValue}
|
||||||
onClose={() => setMenuOpen(false)}
|
|
||||||
onSubmit={(amount, categoryId) => {
|
onSubmit={(amount, categoryId) => {
|
||||||
onBudgetAction(month, 'transfer-available', {
|
onBudgetAction(month, 'transfer-available', {
|
||||||
amount,
|
amount,
|
||||||
category: categoryId,
|
category: categoryId,
|
||||||
});
|
});
|
||||||
|
setMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -132,13 +132,13 @@ export function ToBudget({
|
|||||||
<CoverMenu
|
<CoverMenu
|
||||||
showToBeBudgeted={false}
|
showToBeBudgeted={false}
|
||||||
initialAmount={availableValue}
|
initialAmount={availableValue}
|
||||||
onClose={() => setMenuOpen(false)}
|
|
||||||
onSubmit={(amount, categoryId) => {
|
onSubmit={(amount, categoryId) => {
|
||||||
onBudgetAction(month, 'cover-overbudgeted', {
|
onBudgetAction(month, 'cover-overbudgeted', {
|
||||||
category: categoryId,
|
category: categoryId,
|
||||||
amount,
|
amount,
|
||||||
currencyCode: format.currency.code,
|
currencyCode: format.currency.code,
|
||||||
});
|
});
|
||||||
|
setMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
getCategories,
|
getCategories,
|
||||||
} from '@desktop-client/budget/budgetSlice';
|
} from '@desktop-client/budget/budgetSlice';
|
||||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
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 { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||||
@@ -138,7 +138,7 @@ export function Budget() {
|
|||||||
onShowActivity,
|
onShowActivity,
|
||||||
onReorderCategory,
|
onReorderCategory,
|
||||||
onReorderGroup,
|
onReorderGroup,
|
||||||
} = useCategoryActions();
|
} = useCategoryMutations();
|
||||||
|
|
||||||
if (!initialized || !categoryGroups) {
|
if (!initialized || !categoryGroups) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -202,6 +202,15 @@ export function ExperimentalFeatures() {
|
|||||||
>
|
>
|
||||||
<Trans>Crossover Report</Trans>
|
<Trans>Crossover Report</Trans>
|
||||||
</FeatureToggle>
|
</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 && (
|
{showServerPrefs && (
|
||||||
<ServerFeatureToggle
|
<ServerFeatureToggle
|
||||||
prefName="flags.plugins"
|
prefName="flags.plugins"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React, {
|
|||||||
type ComponentPropsWithoutRef,
|
type ComponentPropsWithoutRef,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
Fragment,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { styles } from '@actual-app/components/styles';
|
import { styles } from '@actual-app/components/styles';
|
||||||
@@ -43,10 +44,15 @@ export function CellValue<
|
|||||||
const { fullSheetName } = useSheetName(binding);
|
const { fullSheetName } = useSheetName(binding);
|
||||||
const sheetValue = useSheetValue(binding);
|
const sheetValue = useSheetValue(binding);
|
||||||
|
|
||||||
|
// Re-render when these value changes.
|
||||||
|
const key = `${fullSheetName}|${sheetValue}`;
|
||||||
return typeof children === 'function' ? (
|
return typeof children === 'function' ? (
|
||||||
<>{children({ type, name: fullSheetName, value: sheetValue })}</>
|
<Fragment key={key}>
|
||||||
|
{children({ type, name: fullSheetName, value: sheetValue })}
|
||||||
|
</Fragment>
|
||||||
) : (
|
) : (
|
||||||
<CellValueText
|
<CellValueText
|
||||||
|
key={key}
|
||||||
type={type}
|
type={type}
|
||||||
name={fullSheetName}
|
name={fullSheetName}
|
||||||
value={sheetValue}
|
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,
|
formulaMode: false,
|
||||||
currency: false,
|
currency: false,
|
||||||
crossoverReport: false,
|
crossoverReport: false,
|
||||||
|
plugins: false,
|
||||||
|
forceReload: false,
|
||||||
|
budgetTableV2: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useFeatureFlag(name: FeatureFlag): boolean {
|
export function useFeatureFlag(name: FeatureFlag): boolean {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ export type FeatureFlag =
|
|||||||
| 'actionTemplating'
|
| 'actionTemplating'
|
||||||
| 'formulaMode'
|
| 'formulaMode'
|
||||||
| 'currency'
|
| 'currency'
|
||||||
| 'crossoverReport';
|
| 'crossoverReport'
|
||||||
|
| 'plugins'
|
||||||
|
| 'forceReload'
|
||||||
|
| 'budgetTableV2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-device preferences. These sync across devices when they are changed.
|
* 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