mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 18:40:34 -05:00
Compare commits
4 Commits
budget-tab
...
mobile-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efaee98d2a | ||
|
|
0b156d1815 | ||
|
|
5e9f38ea45 | ||
|
|
b901e7a6bd |
@@ -6,7 +6,6 @@ import React, {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
@@ -50,7 +49,6 @@ export function NotesButton({
|
||||
const [tempNotes, setTempNotes] = useState<string>(note);
|
||||
useEffect(() => setTempNotes(note), [note, id]);
|
||||
|
||||
const { isFocusVisible } = useFocusVisible();
|
||||
const onOpenChange = useCallback<
|
||||
NonNullable<ComponentProps<typeof Popover>['onOpenChange']>
|
||||
>(
|
||||
@@ -89,9 +87,7 @@ export function NotesButton({
|
||||
...(isOpen && { color: theme.buttonNormalText }),
|
||||
'&:hover': { opacity: 1 },
|
||||
}),
|
||||
!hasNotes && !isOpen && !isFocusVisible && !showPlaceholder
|
||||
? 'hover-visible'
|
||||
: '',
|
||||
!hasNotes && !isOpen && !showPlaceholder ? 'hover-visible' : '',
|
||||
)}
|
||||
data-placeholder={showPlaceholder}
|
||||
onPress={() => {
|
||||
|
||||
@@ -78,10 +78,10 @@ function PrivacyOverlay({ children, ...props }) {
|
||||
display: 'inline-flex',
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
'> div:first-child': {
|
||||
' > div:first-child': {
|
||||
opacity: 0,
|
||||
},
|
||||
'> div:nth-child(2)': {
|
||||
' > div:nth-child(2)': {
|
||||
display: 'flex',
|
||||
},
|
||||
'&:hover': {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ import {
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { BudgetCategories } from './BudgetCategories';
|
||||
import { BudgetCategories as BudgetCategoriesV2 } from './BudgetCategoriesV2';
|
||||
import { BudgetSummaries } from './BudgetSummaries';
|
||||
import { BudgetTotals } from './BudgetTotals';
|
||||
import { type MonthBounds, MonthsProvider } from './MonthsContext';
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import { type DropPosition } from '@desktop-client/components/sort';
|
||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
|
||||
@@ -84,7 +82,6 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
const [editing, setEditing] = useState<{ id: string; cell: string } | null>(
|
||||
null,
|
||||
);
|
||||
const budgetTableV2Enabled = useFeatureFlag('budgetTableV2');
|
||||
|
||||
const onEditMonth = (id: string, month: string) => {
|
||||
setEditing(id ? { id, cell: month } : null);
|
||||
@@ -208,11 +205,15 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
setShowHiddenCategoriesPef(!showHiddenCategories);
|
||||
};
|
||||
|
||||
const onExpandAllCategories = () => {
|
||||
const toggleHiddenCategories = () => {
|
||||
onToggleHiddenCategories();
|
||||
};
|
||||
|
||||
const expandAllCategories = () => {
|
||||
onCollapse([]);
|
||||
};
|
||||
|
||||
const onCollapseAllCategories = () => {
|
||||
const collapseAllCategories = () => {
|
||||
onCollapse(categoryGroups.map(g => g.id));
|
||||
};
|
||||
|
||||
@@ -261,62 +262,45 @@ export function BudgetTable(props: BudgetTableProps) {
|
||||
monthBounds={monthBounds}
|
||||
type={type}
|
||||
>
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
{!budgetTableV2Enabled && (
|
||||
<>
|
||||
<BudgetTotals
|
||||
toggleHiddenCategories={onToggleHiddenCategories}
|
||||
expandAllCategories={onExpandAllCategories}
|
||||
collapseAllCategories={onCollapseAllCategories}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
overflowAnchor: 'none',
|
||||
flex: 1,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<BudgetCategories
|
||||
categoryGroups={categoryGroups}
|
||||
editingCell={editing}
|
||||
onEditMonth={onEditMonth}
|
||||
onEditName={onEditName}
|
||||
onSaveCategory={onSaveCategory}
|
||||
onSaveGroup={onSaveGroup}
|
||||
onDeleteCategory={onDeleteCategory}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onReorderCategory={_onReorderCategory}
|
||||
onReorderGroup={_onReorderGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
onApplyBudgetTemplatesInGroup={
|
||||
onApplyBudgetTemplatesInGroup
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{budgetTableV2Enabled && (
|
||||
<View style={{ overflowY: 'auto' }}>
|
||||
<BudgetCategoriesV2
|
||||
<BudgetTotals
|
||||
toggleHiddenCategories={toggleHiddenCategories}
|
||||
expandAllCategories={expandAllCategories}
|
||||
collapseAllCategories={collapseAllCategories}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
overflowAnchor: 'none',
|
||||
flex: 1,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<BudgetCategories
|
||||
categoryGroups={categoryGroups}
|
||||
editingCell={editing}
|
||||
onEditMonth={onEditMonth}
|
||||
onEditName={onEditName}
|
||||
onSaveCategory={onSaveCategory}
|
||||
onSaveGroup={onSaveGroup}
|
||||
onDeleteCategory={onDeleteCategory}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onReorderCategory={_onReorderCategory}
|
||||
onReorderGroup={_onReorderGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
onToggleHiddenCategories={onToggleHiddenCategories}
|
||||
onExpandAllCategories={onExpandAllCategories}
|
||||
onCollapseAllCategories={onCollapseAllCategories}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</SchedulesProvider>
|
||||
</SchedulesProvider>
|
||||
</View>
|
||||
</View>
|
||||
</MonthsProvider>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { usePress, useFocusable } from 'react-aria';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import { SvgArrowThinRight } from '@actual-app/components/icons/v1';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { type CSSProperties } from '@actual-app/components/styles';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { type CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import { balanceColumnPaddingStyle } from './BudgetCategoriesV2';
|
||||
import { BalanceMovementMenu as EnvelopeBalanceMovementMenu } from './envelope/BalanceMovementMenu';
|
||||
import { BalanceMenu as TrackingBalanceMenu } from './tracking/BalanceMenu';
|
||||
import { makeBalanceAmountStyle } from './util';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryBalanceCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
category: CategoryEntity;
|
||||
onBudgetAction: (month: string, action: string, args: unknown) => void;
|
||||
};
|
||||
|
||||
export function CategoryBalanceCell({
|
||||
month,
|
||||
category,
|
||||
onBudgetAction,
|
||||
style,
|
||||
...props
|
||||
}: CategoryBalanceCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const triggerRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const categoryCarryoverBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catCarryover(category.id)
|
||||
: trackingBudget.catCarryover(category.id);
|
||||
|
||||
const categoryBalanceBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catBalance(category.id)
|
||||
: trackingBudget.catBalance(category.id);
|
||||
|
||||
const categoryBudgetedBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catBudgeted(category.id)
|
||||
: trackingBudget.catBudgeted(category.id);
|
||||
|
||||
const categoryGoalBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catGoal(category.id)
|
||||
: trackingBudget.catGoal(category.id);
|
||||
|
||||
const categoryLongGoalBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catLongGoal(category.id)
|
||||
: trackingBudget.catLongGoal(category.id);
|
||||
|
||||
const budgetedValue = useSheetValue<
|
||||
typeof bindingBudgetType,
|
||||
typeof categoryBudgetedBinding
|
||||
>(categoryBudgetedBinding);
|
||||
|
||||
const goalValue = useSheetValue<
|
||||
typeof bindingBudgetType,
|
||||
typeof categoryGoalBinding
|
||||
>(categoryGoalBinding);
|
||||
|
||||
const longGoalValue = useSheetValue<
|
||||
typeof bindingBudgetType,
|
||||
typeof categoryLongGoalBinding
|
||||
>(categoryLongGoalBinding);
|
||||
|
||||
const [isBalanceMenuOpen, setIsBalanceMenuOpen] = useState(false);
|
||||
|
||||
const { pressProps } = usePress({
|
||||
onPress: () => setIsBalanceMenuOpen(true),
|
||||
});
|
||||
|
||||
const { focusableProps } = useFocusable(
|
||||
{
|
||||
onKeyUp: e => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsBalanceMenuOpen(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
triggerRef,
|
||||
);
|
||||
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
|
||||
const getBalanceAmountStyle = useCallback(
|
||||
(balanceValue: number) =>
|
||||
makeBalanceAmountStyle(
|
||||
balanceValue,
|
||||
isGoalTemplatesEnabled ? goalValue : null,
|
||||
longGoalValue === 1 ? balanceValue : budgetedValue,
|
||||
),
|
||||
[budgetedValue, goalValue, isGoalTemplatesEnabled, longGoalValue],
|
||||
);
|
||||
|
||||
// TODO: Refactor balance cell tooltips
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<typeof bindingBudgetType, typeof categoryBalanceBinding>
|
||||
type="financial"
|
||||
binding={categoryBalanceBinding}
|
||||
>
|
||||
{balanceProps => (
|
||||
<View
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
...balanceColumnPaddingStyle,
|
||||
}}
|
||||
>
|
||||
<CellValueText
|
||||
innerRef={triggerRef}
|
||||
{...pressProps}
|
||||
{...focusableProps}
|
||||
{...balanceProps}
|
||||
className={css({
|
||||
...getBalanceAmountStyle(balanceProps.value),
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<CellValue<
|
||||
typeof bindingBudgetType,
|
||||
typeof categoryCarryoverBinding
|
||||
>
|
||||
binding={categoryCarryoverBinding}
|
||||
>
|
||||
{carryOverProps =>
|
||||
carryOverProps.value && (
|
||||
<CarryoverIndicator
|
||||
style={getBalanceAmountStyle(balanceProps.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</CellValue>
|
||||
</View>
|
||||
)}
|
||||
</CellValue>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
placement="bottom end"
|
||||
isOpen={isBalanceMenuOpen}
|
||||
onOpenChange={() => {
|
||||
setIsBalanceMenuOpen(false);
|
||||
}}
|
||||
isNonModal
|
||||
>
|
||||
{budgetType === 'rollover' ? (
|
||||
<EnvelopeBalanceMovementMenu
|
||||
categoryId={category.id}
|
||||
month={month}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onSelect={() => setIsBalanceMenuOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<TrackingBalanceMenu
|
||||
categoryId={category.id}
|
||||
onCarryover={carryover => {
|
||||
onBudgetAction(month, 'carryover', {
|
||||
category: category.id,
|
||||
flag: carryover,
|
||||
});
|
||||
setIsBalanceMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
type CarryoverIndicatorProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
function CarryoverIndicator({ style }: CarryoverIndicatorProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
transform: 'translateY(-50%)',
|
||||
top: '50%',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<SvgArrowThinRight
|
||||
width={style?.width || 7}
|
||||
height={style?.height || 7}
|
||||
style={style}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef, useState } from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import {
|
||||
currencyToAmount,
|
||||
currencyToInteger,
|
||||
type IntegerAmount,
|
||||
} from 'loot-core/shared/util';
|
||||
import type { CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
hoverVisibleStyle,
|
||||
getCellBackgroundStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { BudgetMenu as EnvelopeBudgetMenu } from './envelope/BudgetMenu';
|
||||
import { BudgetMenu as TrackingBudgetMenu } from './tracking/BudgetMenu';
|
||||
import { makeAmountGrey } from './util';
|
||||
|
||||
import { CellValue } from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryBudgetedCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
category: CategoryEntity;
|
||||
onBudgetAction: (month: string, action: string, args: unknown) => void;
|
||||
};
|
||||
|
||||
export function CategoryBudgetedCell({
|
||||
month,
|
||||
category,
|
||||
onBudgetAction,
|
||||
...props
|
||||
}: CategoryBudgetedCellProps) {
|
||||
const { t } = useTranslation();
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [shouldHideBudgetMenuButton, setShouldHideBudgetMenuButton] =
|
||||
useState(false);
|
||||
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const budgetedBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.catBudgeted(category.id)
|
||||
: trackingBudget.catBudgeted(category.id);
|
||||
|
||||
const BudgetMenuComponent =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? EnvelopeBudgetMenu
|
||||
: TrackingBudgetMenu;
|
||||
|
||||
const { showUndoNotification } = useUndo();
|
||||
|
||||
const onUpdateBudget = (amount: IntegerAmount) => {
|
||||
onBudgetAction(month, 'budget-amount', {
|
||||
category: category.id,
|
||||
amount,
|
||||
});
|
||||
};
|
||||
|
||||
const { isFocusVisible } = useFocusVisible();
|
||||
|
||||
return (
|
||||
<ReactAriaCell {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<View
|
||||
className={css({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
...hoverVisibleStyle,
|
||||
})}
|
||||
>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Budget menu')}
|
||||
className={cx(
|
||||
{ 'hover-visible': !isMenuOpen && !isFocusVisible },
|
||||
css({
|
||||
display:
|
||||
shouldHideBudgetMenuButton && !isFocusVisible
|
||||
? 'none'
|
||||
: undefined,
|
||||
}),
|
||||
)}
|
||||
onPress={() => setIsMenuOpen(true)}
|
||||
>
|
||||
<SvgCheveronDown width={12} height={12} />
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
placement="bottom start"
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={() => setIsMenuOpen(false)}
|
||||
isNonModal
|
||||
>
|
||||
<BudgetMenuComponent
|
||||
onCopyLastMonthAverage={() => {
|
||||
onBudgetAction(month, 'copy-single-last', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget set to last month's budget.`),
|
||||
});
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
if (
|
||||
numberOfMonths !== 3 &&
|
||||
numberOfMonths !== 6 &&
|
||||
numberOfMonths !== 12
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Budget set to {{numberOfMonths}}-month average.',
|
||||
{ numberOfMonths },
|
||||
),
|
||||
});
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
onApplyBudgetTemplate={() => {
|
||||
onBudgetAction(month, 'apply-single-category-template', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget template applied.`),
|
||||
});
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
<View style={{ flex: 1 }}>
|
||||
<CellValue<typeof bindingBudgetType, typeof budgetedBinding>
|
||||
type="financial"
|
||||
binding={budgetedBinding}
|
||||
>
|
||||
{({ value: budgetedAmount }) => (
|
||||
<BudgetedInput
|
||||
value={budgetedAmount}
|
||||
onFocus={() => setShouldHideBudgetMenuButton(true)}
|
||||
onBlur={() => setShouldHideBudgetMenuButton(false)}
|
||||
style={getCellBackgroundStyle('budgeted', month)}
|
||||
onUpdateAmount={onUpdateBudget}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</View>
|
||||
</View>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
|
||||
type BudgetedInputProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof Input>,
|
||||
'value'
|
||||
> & {
|
||||
value: IntegerAmount;
|
||||
onUpdateAmount: (newValue: IntegerAmount) => void;
|
||||
};
|
||||
|
||||
function BudgetedInput({
|
||||
value,
|
||||
onFocus,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
onUpdateAmount,
|
||||
...props
|
||||
}: BudgetedInputProps) {
|
||||
const format = useFormat();
|
||||
const [currentFormattedAmount, setCurrentFormattedAmount] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={currentFormattedAmount ?? format(value, 'financial')}
|
||||
onFocus={e => {
|
||||
onFocus?.(e);
|
||||
if (!e.defaultPrevented) {
|
||||
e.target.select();
|
||||
}
|
||||
}}
|
||||
onEscape={() => setCurrentFormattedAmount(format(value, 'financial'))}
|
||||
className={css({
|
||||
...makeAmountGrey(
|
||||
currentFormattedAmount
|
||||
? currencyToAmount(currentFormattedAmount)
|
||||
: value,
|
||||
),
|
||||
textAlign: 'right',
|
||||
border: '1px solid transparent',
|
||||
'&:hover:not(:focus)': {
|
||||
border: `1px solid ${theme.formInputBorder}`,
|
||||
},
|
||||
})}
|
||||
onChangeValue={(newValue, e) => {
|
||||
onChangeValue?.(newValue, e);
|
||||
setCurrentFormattedAmount(newValue);
|
||||
}}
|
||||
onUpdate={(newValue, e) => {
|
||||
onUpdate?.(newValue, e);
|
||||
const integerAmount = currencyToInteger(newValue);
|
||||
if (integerAmount) {
|
||||
onUpdateAmount?.(integerAmount);
|
||||
setCurrentFormattedAmount(format(integerAmount, 'financial'));
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import { balanceColumnPaddingStyle } from './BudgetCategoriesV2';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryGroupBalanceCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
};
|
||||
|
||||
export function CategoryGroupBalanceCell({
|
||||
month,
|
||||
categoryGroup,
|
||||
style,
|
||||
...props
|
||||
}: CategoryGroupBalanceCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const groupBalanceBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.groupBalance(categoryGroup.id)
|
||||
: trackingBudget.groupBalance(categoryGroup.id);
|
||||
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<typeof bindingBudgetType, typeof groupBalanceBinding>
|
||||
type="financial"
|
||||
binding={groupBalanceBinding}
|
||||
>
|
||||
{props => (
|
||||
<CellValueText
|
||||
{...props}
|
||||
style={{
|
||||
...balanceColumnPaddingStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryGroupBudgetedCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
};
|
||||
|
||||
export function CategoryGroupBudgetedCell({
|
||||
month,
|
||||
categoryGroup,
|
||||
style,
|
||||
...props
|
||||
}: CategoryGroupBudgetedCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const groupBudgetedBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.groupBudgeted(categoryGroup.id)
|
||||
: trackingBudget.groupBudgeted(categoryGroup.id);
|
||||
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<typeof bindingBudgetType, typeof groupBudgetedBinding>
|
||||
type="financial"
|
||||
binding={groupBudgetedBinding}
|
||||
>
|
||||
{props => (
|
||||
<CellValueText
|
||||
{...props}
|
||||
style={{
|
||||
paddingRight: 5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef, useState } from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgAdd, SvgExpandArrow } from '@actual-app/components/icons/v0';
|
||||
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import { hoverVisibleStyle } from './BudgetCategoriesV2';
|
||||
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
|
||||
type CategoryGroupNameCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
|
||||
onDelete: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
|
||||
};
|
||||
|
||||
export function CategoryGroupNameCell({
|
||||
month,
|
||||
categoryGroup,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onAddCategory,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibilty,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
...props
|
||||
}: CategoryGroupNameCellProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const { isFocusVisible } = useFocusVisible();
|
||||
|
||||
return (
|
||||
<ReactAriaCell {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<View
|
||||
className={css({
|
||||
paddingLeft: 5,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...hoverVisibleStyle,
|
||||
})}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||
{/* Hidden drag button */}
|
||||
<Button
|
||||
slot="drag"
|
||||
style={{
|
||||
opacity: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onToggleCollapse(categoryGroup)}
|
||||
isDisabled={isRenaming}
|
||||
>
|
||||
<SvgExpandArrow
|
||||
width={8}
|
||||
height={8}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
transition: 'transform .1s',
|
||||
transform: isCollapsed ? 'rotate(-90deg)' : '',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{isRenaming ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
autoFocus
|
||||
defaultValue={categoryGroup.name}
|
||||
onBlur={() => setIsRenaming(false)}
|
||||
onEscape={() => setIsRenaming(false)}
|
||||
onUpdate={newName => {
|
||||
if (newName !== categoryGroup.name) {
|
||||
onRename(categoryGroup, newName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ fontWeight: 600 }}>{categoryGroup.name}</Text>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
className={cx({
|
||||
'hover-visible': !isMenuOpen && !isFocusVisible,
|
||||
})}
|
||||
onPress={() => {
|
||||
// resetPosition();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<SvgCheveronDown width={12} height={12} />
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
placement="bottom start"
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={() => setIsMenuOpen(false)}
|
||||
isNonModal
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={type => {
|
||||
if (type === 'rename') {
|
||||
// onEdit(categoryGroup.id);
|
||||
setIsRenaming(true);
|
||||
} else if (type === 'delete') {
|
||||
onDelete(categoryGroup);
|
||||
} else if (type === 'toggle-visibility') {
|
||||
// onSave({ ...categoryGroup, hidden: !categoryGroup.hidden });
|
||||
onToggleVisibilty(categoryGroup);
|
||||
} else if (
|
||||
type === 'apply-multiple-category-template'
|
||||
) {
|
||||
onApplyBudgetTemplatesInGroup(categoryGroup);
|
||||
}
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
items={[
|
||||
{ name: 'rename', text: t('Rename') },
|
||||
...(!categoryGroup.is_income
|
||||
? [
|
||||
{
|
||||
name: 'toggle-visibility',
|
||||
text: categoryGroup.hidden ? 'Show' : 'Hide',
|
||||
},
|
||||
{ name: 'delete', text: t('Delete') },
|
||||
]
|
||||
: []),
|
||||
...(isGoalTemplatesEnabled
|
||||
? [
|
||||
{
|
||||
name: 'apply-multiple-category-template',
|
||||
text: t('Overwrite with templates'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
{!isRenaming && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Tooltip content={t('Add category')} disablePointerEvents>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Add category')}
|
||||
className={cx(
|
||||
css({
|
||||
color: theme.pageTextLight,
|
||||
}),
|
||||
'hover-visible',
|
||||
)}
|
||||
onPress={() => {
|
||||
onAddCategory(categoryGroup);
|
||||
}}
|
||||
>
|
||||
<SvgAdd style={{ width: 10, height: 10, flexShrink: 0 }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<NotesButton
|
||||
id={categoryGroup.id}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { type SheetNames } from '@desktop-client/spreadsheet';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryGroupSpentCellProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaCell
|
||||
> & {
|
||||
month: string;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
};
|
||||
|
||||
export function CategoryGroupSpentCell({
|
||||
month,
|
||||
categoryGroup,
|
||||
style,
|
||||
...props
|
||||
}: CategoryGroupSpentCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const bindingBudgetType: SheetNames =
|
||||
budgetType === 'rollover' ? 'envelope-budget' : 'tracking-budget';
|
||||
|
||||
const groupSpentBinding =
|
||||
bindingBudgetType === 'envelope-budget'
|
||||
? envelopeBudget.groupSumAmount(categoryGroup.id)
|
||||
: trackingBudget.groupSumAmount(categoryGroup.id);
|
||||
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<typeof bindingBudgetType, typeof groupSpentBinding>
|
||||
type="financial"
|
||||
binding={groupSpentBinding}
|
||||
>
|
||||
{props => (
|
||||
<CellValueText
|
||||
{...props}
|
||||
style={{
|
||||
paddingRight: 5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef, useState } from 'react';
|
||||
import { useFocusVisible } from 'react-aria';
|
||||
import { Cell as ReactAriaCell, DialogTrigger } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type {
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { hoverVisibleStyle } from './BudgetCategoriesV2';
|
||||
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
|
||||
type CategoryNameCellProps = ComponentPropsWithoutRef<typeof ReactAriaCell> & {
|
||||
month: string;
|
||||
category: CategoryEntity;
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
onRename: (category: CategoryEntity, newName: string) => void;
|
||||
onDelete: (category: CategoryEntity) => void;
|
||||
onToggleVisibility: (category: CategoryEntity) => void;
|
||||
};
|
||||
|
||||
export function CategoryNameCell({
|
||||
month,
|
||||
category,
|
||||
categoryGroup,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
...props
|
||||
}: CategoryNameCellProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const { isFocusVisible } = useFocusVisible();
|
||||
|
||||
return (
|
||||
<ReactAriaCell {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<View
|
||||
className={css({
|
||||
paddingLeft: 18,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...hoverVisibleStyle,
|
||||
})}
|
||||
>
|
||||
{/* Hidden drag button */}
|
||||
<Button
|
||||
slot="drag"
|
||||
style={{
|
||||
opacity: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
{isRenaming ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Input
|
||||
defaultValue={category.name}
|
||||
placeholder={t('Enter category name')}
|
||||
onBlur={() => setIsRenaming(false)}
|
||||
onUpdate={newName => {
|
||||
if (newName !== category.name) {
|
||||
onRename(category, newName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<Text>{category.name}</Text>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
className={cx({
|
||||
'hover-visible': !isMenuOpen && !isFocusVisible,
|
||||
})}
|
||||
onPress={() => {
|
||||
// resetPosition();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<SvgCheveronDown width={12} height={12} />
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
placement="bottom start"
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={() => setIsMenuOpen(false)}
|
||||
isNonModal
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={type => {
|
||||
if (type === 'rename') {
|
||||
// onEditName(category.id);
|
||||
setIsRenaming(true);
|
||||
} else if (type === 'delete') {
|
||||
onDelete(category);
|
||||
} else if (type === 'toggle-visibility') {
|
||||
// onSave({ ...category, hidden: !category.hidden });
|
||||
onToggleVisibility(category);
|
||||
}
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
items={[
|
||||
{ name: 'rename', text: t('Rename') },
|
||||
...(!categoryGroup?.hidden
|
||||
? [
|
||||
{
|
||||
name: 'toggle-visibility',
|
||||
text: category.hidden ? t('Show') : t('Hide'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ name: 'delete', text: t('Delete') },
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</View>
|
||||
<View>
|
||||
<NotesButton
|
||||
id={category.id}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef, useRef } from 'react';
|
||||
import { usePress, useFocusable } from 'react-aria';
|
||||
import { Cell as ReactAriaCell } from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import { makeAmountGrey } from './util';
|
||||
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import {
|
||||
envelopeBudget,
|
||||
trackingBudget,
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategorySpentCellProps = ComponentPropsWithoutRef<typeof ReactAriaCell> & {
|
||||
month: string;
|
||||
category: CategoryEntity;
|
||||
onShowActivity: (category: CategoryEntity, month: string) => void;
|
||||
};
|
||||
|
||||
export function CategorySpentCell({
|
||||
month,
|
||||
category,
|
||||
onShowActivity,
|
||||
style,
|
||||
...props
|
||||
}: CategorySpentCellProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
|
||||
const categorySpentBinding =
|
||||
budgetType === 'rollover'
|
||||
? envelopeBudget.catSumAmount(category.id)
|
||||
: trackingBudget.catSumAmount(category.id);
|
||||
|
||||
const { pressProps } = usePress({
|
||||
onPress: () => onShowActivity(category, month),
|
||||
});
|
||||
|
||||
const textRef = useRef<HTMLSpanElement | null>(null);
|
||||
const { focusableProps } = useFocusable(
|
||||
{
|
||||
onKeyUp: e => {
|
||||
if (e.key === 'Enter') {
|
||||
onShowActivity(category, month);
|
||||
}
|
||||
},
|
||||
},
|
||||
textRef,
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactAriaCell style={{ textAlign: 'right', ...style }} {...props}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<CellValue<'envelope-budget', 'sum-amount'>
|
||||
type="financial"
|
||||
binding={categorySpentBinding}
|
||||
>
|
||||
{props => (
|
||||
<CellValueText
|
||||
innerRef={textRef}
|
||||
{...pressProps}
|
||||
{...focusableProps}
|
||||
{...props}
|
||||
className={css({
|
||||
...makeAmountGrey(props.value),
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
paddingRight: 5,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</CellValue>
|
||||
</SheetNameProvider>
|
||||
</ReactAriaCell>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { useBudgetMonthCount } from './BudgetMonthCountContext';
|
||||
import { BudgetPageHeader } from './BudgetPageHeader';
|
||||
import { BudgetTable } from './BudgetTable';
|
||||
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
|
||||
function getNumPossibleMonths(width: number, categoryWidth: number) {
|
||||
@@ -79,12 +78,8 @@ const DynamicBudgetTable = ({
|
||||
onMonthSelect(getValidMonth(month), numMonths);
|
||||
}
|
||||
|
||||
// Table V2 uses alt+left/right for month navigation
|
||||
// so that users can use left/right to navigate cells
|
||||
const budgetTableV2Enabled = useFeatureFlag('budgetTableV2');
|
||||
|
||||
useHotkeys(
|
||||
budgetTableV2Enabled ? 'alt+left' : 'left',
|
||||
'left',
|
||||
() => {
|
||||
_onMonthSelect(monthUtils.prevMonth(startMonth));
|
||||
},
|
||||
@@ -95,7 +90,7 @@ const DynamicBudgetTable = ({
|
||||
[_onMonthSelect, startMonth],
|
||||
);
|
||||
useHotkeys(
|
||||
budgetTableV2Enabled ? 'alt+right' : 'right',
|
||||
'right',
|
||||
() => {
|
||||
_onMonthSelect(monthUtils.nextMonth(startMonth));
|
||||
},
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Row as ReactAriaRow } from 'react-aria-components';
|
||||
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getCategoryGroupRowStyle,
|
||||
getHeaderBackgroundStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { CategoryGroupBalanceCell } from './CategoryGroupBalanceCell';
|
||||
import { CategoryGroupBudgetedCell } from './CategoryGroupBudgetedCell';
|
||||
import { CategoryGroupNameCell } from './CategoryGroupNameCell';
|
||||
import { CategoryGroupSpentCell } from './CategoryGroupSpentCell';
|
||||
|
||||
type ExpenseCategoryGroupRowProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaRow<ColumnDefinition>
|
||||
> & {
|
||||
item: {
|
||||
type: 'expense-group';
|
||||
id: `expense-group-${string}`;
|
||||
value: CategoryGroupEntity;
|
||||
};
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
|
||||
onDelete: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
|
||||
};
|
||||
|
||||
export function ExpenseCategoryGroupRow({
|
||||
item,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onAddCategory,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibilty,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
style,
|
||||
...props
|
||||
}: ExpenseCategoryGroupRowProps) {
|
||||
return (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryGroupRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryGroupNameCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onAddCategory={onAddCategory}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibilty={onToggleVisibilty}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return (
|
||||
<CategoryGroupBudgetedCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
style={getHeaderBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
case 'spent':
|
||||
return (
|
||||
<CategoryGroupSpentCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
style={getHeaderBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryGroupBalanceCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
style={getHeaderBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Row as ReactAriaRow } from 'react-aria-components';
|
||||
|
||||
import type {
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getCategoryRowStyle,
|
||||
getCellBackgroundStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { CategoryBalanceCell } from './CategoryBalanceCell';
|
||||
import { CategoryBudgetedCell } from './CategoryBudgetedCell';
|
||||
import { CategoryNameCell } from './CategoryNameCell';
|
||||
import { CategorySpentCell } from './CategorySpentCell';
|
||||
|
||||
type ExpenseCategoryRowProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaRow<ColumnDefinition>
|
||||
> & {
|
||||
item: {
|
||||
type: 'expense-category';
|
||||
id: `expense-category-${string}`;
|
||||
value: CategoryEntity;
|
||||
group: CategoryGroupEntity;
|
||||
};
|
||||
onBudgetAction: (month: string, action: string, args: unknown) => void;
|
||||
onShowActivity: (category: CategoryEntity, month: string) => void;
|
||||
onRename: (category: CategoryEntity, newName: string) => void;
|
||||
onDelete: (category: CategoryEntity) => void;
|
||||
onToggleVisibility: (category: CategoryEntity) => void;
|
||||
};
|
||||
export function ExpenseCategoryRow({
|
||||
item,
|
||||
onBudgetAction,
|
||||
onShowActivity,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
style,
|
||||
...props
|
||||
}: ExpenseCategoryRowProps) {
|
||||
return (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryRowStyle(item.value, item.group),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryNameCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
categoryGroup={item.group}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return (
|
||||
<CategoryBudgetedCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
style={getCellBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
case 'spent':
|
||||
return (
|
||||
<CategorySpentCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onShowActivity={onShowActivity}
|
||||
style={getCellBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryBalanceCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
style={getCellBackgroundStyle(column.type, column.month)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import {
|
||||
Row as ReactAriaRow,
|
||||
Cell as ReactAriaCell,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import type { CategoryGroupEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getCategoryGroupRowStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { CategoryGroupBalanceCell } from './CategoryGroupBalanceCell';
|
||||
import { CategoryGroupBudgetedCell } from './CategoryGroupBudgetedCell';
|
||||
import { CategoryGroupNameCell } from './CategoryGroupNameCell';
|
||||
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
|
||||
type IncomeCategoryGroupRowProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaRow<ColumnDefinition>
|
||||
> & {
|
||||
item: {
|
||||
type: 'income-group';
|
||||
id: `income-group-${string}`;
|
||||
value: CategoryGroupEntity;
|
||||
};
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onAddCategory: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onRename: (categoryGroup: CategoryGroupEntity, newName: string) => void;
|
||||
onDelete: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onToggleVisibilty: (categoryGroup: CategoryGroupEntity) => void;
|
||||
onApplyBudgetTemplatesInGroup: (categoryGroup: CategoryGroupEntity) => void;
|
||||
};
|
||||
|
||||
export function IncomeCategoryGroupRow({
|
||||
item,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onAddCategory,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibilty,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
style,
|
||||
...props
|
||||
}: IncomeCategoryGroupRowProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
|
||||
return budgetType === 'rollover' ? (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryGroupRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryGroupNameCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onAddCategory={onAddCategory}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibilty={onToggleVisibilty}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return <ReactAriaCell />;
|
||||
case 'spent':
|
||||
return <ReactAriaCell />;
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryGroupBalanceCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
) : (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryGroupRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryGroupNameCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onAddCategory={onAddCategory}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibilty={onToggleVisibilty}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return (
|
||||
<CategoryGroupBudgetedCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
/>
|
||||
);
|
||||
case 'spent':
|
||||
return <ReactAriaCell />;
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryGroupBalanceCell
|
||||
month={column.month}
|
||||
categoryGroup={item.value}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import {
|
||||
Row as ReactAriaRow,
|
||||
Cell as ReactAriaCell,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import type {
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getCategoryRowStyle,
|
||||
} from './BudgetCategoriesV2';
|
||||
import { CategoryBalanceCell } from './CategoryBalanceCell';
|
||||
import { CategoryBudgetedCell } from './CategoryBudgetedCell';
|
||||
import { CategoryNameCell } from './CategoryNameCell';
|
||||
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
|
||||
type IncomeCategoryRowProps = ComponentPropsWithoutRef<
|
||||
typeof ReactAriaRow<ColumnDefinition>
|
||||
> & {
|
||||
item: {
|
||||
type: 'income-category';
|
||||
id: `income-category-${string}`;
|
||||
value: CategoryEntity;
|
||||
group: CategoryGroupEntity;
|
||||
};
|
||||
onBudgetAction: (month: string, action: string, args: unknown) => void;
|
||||
onRename: (category: CategoryEntity, newName: string) => void;
|
||||
onDelete: (category: CategoryEntity) => void;
|
||||
onToggleVisibility: (category: CategoryEntity) => void;
|
||||
};
|
||||
export function IncomeCategoryRow({
|
||||
item,
|
||||
onBudgetAction,
|
||||
onRename,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
style,
|
||||
...props
|
||||
}: IncomeCategoryRowProps) {
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
return budgetType === 'rollover' ? (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryNameCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
categoryGroup={item.group}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return <ReactAriaCell />;
|
||||
case 'spent':
|
||||
return <ReactAriaCell />;
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryBalanceCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
) : (
|
||||
<ReactAriaRow
|
||||
style={{
|
||||
...getCategoryRowStyle(item.value),
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{column => {
|
||||
switch (column.type) {
|
||||
case 'category':
|
||||
return (
|
||||
<CategoryNameCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
categoryGroup={item.group}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
/>
|
||||
);
|
||||
case 'budgeted':
|
||||
return (
|
||||
<CategoryBudgetedCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
);
|
||||
case 'spent':
|
||||
return <ReactAriaCell />;
|
||||
case 'balance':
|
||||
return (
|
||||
<CategoryBalanceCell
|
||||
month={column.month}
|
||||
category={item.value}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unrecognized column type: ${column.type}`);
|
||||
}
|
||||
}}
|
||||
</ReactAriaRow>
|
||||
);
|
||||
}
|
||||
@@ -12,14 +12,14 @@ type BalanceMovementMenuProps = {
|
||||
categoryId: string;
|
||||
month: string;
|
||||
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
||||
onSelect?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export function BalanceMovementMenu({
|
||||
categoryId,
|
||||
month,
|
||||
onBudgetAction,
|
||||
onSelect = () => {},
|
||||
onClose = () => {},
|
||||
}: BalanceMovementMenuProps) {
|
||||
const format = useFormat();
|
||||
|
||||
@@ -48,7 +48,7 @@ export function BalanceMovementMenu({
|
||||
category: categoryId,
|
||||
flag: carryover,
|
||||
});
|
||||
onSelect();
|
||||
onClose();
|
||||
}}
|
||||
onTransfer={() => setMenu('transfer')}
|
||||
onCover={() => setMenu('cover')}
|
||||
@@ -60,6 +60,7 @@ export function BalanceMovementMenu({
|
||||
categoryId={categoryId}
|
||||
initialAmount={catBalance}
|
||||
showToBeBudgeted
|
||||
onClose={onClose}
|
||||
onSubmit={(amount, toCategoryId) => {
|
||||
onBudgetAction(month, 'transfer-category', {
|
||||
amount,
|
||||
@@ -67,7 +68,6 @@ export function BalanceMovementMenu({
|
||||
to: toCategoryId,
|
||||
currencyCode: format.currency.code,
|
||||
});
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -76,6 +76,7 @@ export function BalanceMovementMenu({
|
||||
<CoverMenu
|
||||
categoryId={categoryId}
|
||||
initialAmount={catBalance}
|
||||
onClose={onClose}
|
||||
onSubmit={(amount, fromCategoryId) => {
|
||||
onBudgetAction(month, 'cover-overspending', {
|
||||
to: categoryId,
|
||||
@@ -83,7 +84,6 @@ export function BalanceMovementMenu({
|
||||
amount,
|
||||
currencyCode: format.currency.code,
|
||||
});
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ type CoverMenuProps = {
|
||||
initialAmount?: IntegerAmount | null;
|
||||
categoryId?: CategoryEntity['id'];
|
||||
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CoverMenu({
|
||||
@@ -34,6 +35,7 @@ export function CoverMenu({
|
||||
initialAmount = 0,
|
||||
categoryId,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: CoverMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -59,6 +61,7 @@ export function CoverMenu({
|
||||
if (parsedAmount && fromCategoryId) {
|
||||
onSubmit(amountToInteger(parsedAmount), fromCategoryId);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -508,7 +508,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
categoryId={category.id}
|
||||
month={month}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onSelect={() => setBalanceMenuOpen(false)}
|
||||
onClose={() => setBalanceMenuOpen(false)}
|
||||
/>
|
||||
</Popover>
|
||||
</Field>
|
||||
|
||||
@@ -22,6 +22,7 @@ type TransferMenuProps = {
|
||||
initialAmount?: IntegerAmount | null;
|
||||
showToBeBudgeted?: boolean;
|
||||
onSubmit: (amount: IntegerAmount, categoryId: CategoryEntity['id']) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function TransferMenu({
|
||||
@@ -29,6 +30,7 @@ export function TransferMenu({
|
||||
initialAmount = 0,
|
||||
showToBeBudgeted,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: TransferMenuProps) {
|
||||
const { grouped: originalCategoryGroups } = useCategories();
|
||||
const filteredCategoryGroups = useMemo(() => {
|
||||
@@ -52,6 +54,7 @@ export function TransferMenu({
|
||||
if (amount != null && amount > 0 && toCategoryId) {
|
||||
onSubmit(amount, toCategoryId);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -119,12 +119,12 @@ export function ToBudget({
|
||||
{menuStep === 'transfer' && (
|
||||
<TransferMenu
|
||||
initialAmount={availableValue}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onSubmit={(amount, categoryId) => {
|
||||
onBudgetAction(month, 'transfer-available', {
|
||||
amount,
|
||||
category: categoryId,
|
||||
});
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -132,13 +132,13 @@ export function ToBudget({
|
||||
<CoverMenu
|
||||
showToBeBudgeted={false}
|
||||
initialAmount={availableValue}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onSubmit={(amount, categoryId) => {
|
||||
onBudgetAction(month, 'cover-overbudgeted', {
|
||||
category: categoryId,
|
||||
amount,
|
||||
currencyCode: format.currency.code,
|
||||
});
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getCategories,
|
||||
} from '@desktop-client/budget/budgetSlice';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useCategoryMutations } from '@desktop-client/hooks/useCategoryMutations';
|
||||
import { useCategoryActions } from '@desktop-client/hooks/useCategoryActions';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
@@ -138,7 +138,7 @@ export function Budget() {
|
||||
onShowActivity,
|
||||
onReorderCategory,
|
||||
onReorderGroup,
|
||||
} = useCategoryMutations();
|
||||
} = useCategoryActions();
|
||||
|
||||
if (!initialized || !categoryGroups) {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type DragItem } from 'react-aria';
|
||||
import { isTextDropItem, type DragItem } from 'react-aria';
|
||||
import { DropIndicator, GridList, useDragAndDrop } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
import { ExpenseCategoryListItem } from './ExpenseCategoryListItem';
|
||||
|
||||
import { moveCategory } from '@desktop-client/budget/budgetSlice';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
const DRAG_TYPE = 'mobile-expense-category-list/category-id';
|
||||
|
||||
type ExpenseCategoryListProps = {
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
categories: CategoryEntity[];
|
||||
@@ -37,14 +40,14 @@ export function ExpenseCategoryList({
|
||||
shouldHideCategory,
|
||||
}: ExpenseCategoryListProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const { reorderCategory } = useReorderCategory();
|
||||
|
||||
const { dragAndDropHooks } = useDragAndDrop({
|
||||
getItems: keys =>
|
||||
[...keys].map(
|
||||
key =>
|
||||
({
|
||||
'text/plain': key as CategoryEntity['id'],
|
||||
[DRAG_TYPE]: key as CategoryEntity['id'],
|
||||
}) as DragItem,
|
||||
),
|
||||
renderDropIndicator: target => {
|
||||
@@ -54,7 +57,7 @@ export function ExpenseCategoryList({
|
||||
className={css({
|
||||
'&[data-drop-target]': {
|
||||
height: 4,
|
||||
backgroundColor: theme.tableBorderSeparator,
|
||||
backgroundColor: theme.tableBorderHover,
|
||||
opacity: 1,
|
||||
borderRadius: 4,
|
||||
},
|
||||
@@ -62,59 +65,25 @@ export function ExpenseCategoryList({
|
||||
/>
|
||||
);
|
||||
},
|
||||
acceptedDragTypes: [DRAG_TYPE],
|
||||
getDropOperation: () => 'move',
|
||||
onInsert: async e => {
|
||||
const [id] = await Promise.all(
|
||||
e.items.filter(isTextDropItem).map(item => item.getText(DRAG_TYPE)),
|
||||
);
|
||||
reorderCategory({
|
||||
id: id as CategoryEntity['id'],
|
||||
targetId: e.target.key as CategoryEntity['id'],
|
||||
dropPosition: e.target.dropPosition,
|
||||
});
|
||||
},
|
||||
onReorder: e => {
|
||||
const [key] = e.keys;
|
||||
const categoryIdToMove = key as CategoryEntity['id'];
|
||||
const categoryToMove = categories.find(c => c.id === categoryIdToMove);
|
||||
|
||||
if (!categoryToMove) {
|
||||
throw new Error(
|
||||
`Internal error: category with ID ${categoryIdToMove} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!categoryToMove.group) {
|
||||
throw new Error(
|
||||
`Internal error: category ${categoryIdToMove} is not in a group and cannot be moved.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetCategoryId = e.target.key as CategoryEntity['id'];
|
||||
|
||||
if (e.target.dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: categoryToMove.id,
|
||||
groupId: categoryToMove.group,
|
||||
targetId: targetCategoryId,
|
||||
}),
|
||||
);
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
const targetCategoryIndex = categories.findIndex(
|
||||
c => c.id === targetCategoryId,
|
||||
);
|
||||
|
||||
if (targetCategoryIndex === -1) {
|
||||
throw new Error(
|
||||
`Internal error: category with ID ${targetCategoryId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextToTargetCategory = categories[targetCategoryIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: categoryToMove.id,
|
||||
groupId: categoryToMove.group,
|
||||
// Due to the way `moveCategory` works, we use the category next to the
|
||||
// actual target category here because `moveCategory` always shoves the
|
||||
// category *before* the target category.
|
||||
// On the other hand, using `null` as `targetId` moves the category
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetCategory?.id || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
reorderCategory({
|
||||
id: key as CategoryEntity['id'],
|
||||
targetId: e.target.key as CategoryEntity['id'],
|
||||
dropPosition: e.target.dropPosition,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -149,3 +118,76 @@ export function ExpenseCategoryList({
|
||||
</GridList>
|
||||
);
|
||||
}
|
||||
|
||||
function useReorderCategory() {
|
||||
const dispatch = useDispatch();
|
||||
const { list: categories } = useCategories();
|
||||
const reorderCategory = ({
|
||||
id,
|
||||
targetId,
|
||||
dropPosition,
|
||||
}: {
|
||||
id: CategoryEntity['id'];
|
||||
targetId: CategoryEntity['id'];
|
||||
dropPosition: 'on' | 'before' | 'after';
|
||||
}) => {
|
||||
const categoryToMove = categories.find(c => c.id === id);
|
||||
|
||||
if (!categoryToMove) {
|
||||
throw new Error(`Internal error: category with ID ${id} not found.`);
|
||||
}
|
||||
|
||||
if (!categoryToMove.group) {
|
||||
throw new Error(
|
||||
`Internal error: Failed to move category ${id} because it is not in a group.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetCategoryGroupId = categories.find(
|
||||
c => c.id === targetId,
|
||||
)?.group;
|
||||
|
||||
if (!targetCategoryGroupId) {
|
||||
throw new Error(
|
||||
`Internal error: Failed to move category ${id} because target category ${targetId} is not in a group.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: categoryToMove.id,
|
||||
groupId: targetCategoryGroupId,
|
||||
targetId,
|
||||
}),
|
||||
);
|
||||
} else if (dropPosition === 'after') {
|
||||
const targetCategoryIndex = categories.findIndex(c => c.id === targetId);
|
||||
|
||||
if (targetCategoryIndex === -1) {
|
||||
throw new Error(
|
||||
`Internal error: category with ID ${targetId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextToTargetCategory = categories[targetCategoryIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: categoryToMove.id,
|
||||
groupId: targetCategoryGroupId,
|
||||
// Due to the way `moveCategory` works, we use the category next to the
|
||||
// actual target category here because `moveCategory` always shoves the
|
||||
// category *before* the target category.
|
||||
// On the other hand, using `null` as `targetId` moves the category
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetCategory?.id || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
reorderCategory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
} from './ExpenseGroupListItem';
|
||||
|
||||
import { moveCategoryGroup } from '@desktop-client/budget/budgetSlice';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
const DRAG_TYPE = 'mobile-expense-group-list/category-group-id';
|
||||
|
||||
type ExpenseGroupListProps = {
|
||||
categoryGroups: CategoryGroupEntity[];
|
||||
show3Columns: boolean;
|
||||
@@ -44,14 +47,14 @@ export function ExpenseGroupList({
|
||||
onToggleCollapse,
|
||||
}: ExpenseGroupListProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { reorderCategoryGroup } = useReorderCategoryGroup();
|
||||
const { dragAndDropHooks } = useDragAndDrop({
|
||||
getItems: keys =>
|
||||
[...keys].map(
|
||||
key =>
|
||||
({
|
||||
'text/plain': key as CategoryEntity['id'],
|
||||
[DRAG_TYPE]: key as CategoryGroupEntity['id'],
|
||||
}) as DragItem,
|
||||
),
|
||||
renderDropIndicator: target => {
|
||||
@@ -61,7 +64,7 @@ export function ExpenseGroupList({
|
||||
className={css({
|
||||
'&[data-drop-target]': {
|
||||
height: 4,
|
||||
backgroundColor: theme.tableBorderSeparator,
|
||||
backgroundColor: theme.tableBorderHover,
|
||||
opacity: 1,
|
||||
borderRadius: 4,
|
||||
},
|
||||
@@ -70,7 +73,7 @@ export function ExpenseGroupList({
|
||||
);
|
||||
},
|
||||
renderDragPreview: items => {
|
||||
const draggedGroupId = items[0]['text/plain'];
|
||||
const draggedGroupId = items[0][DRAG_TYPE];
|
||||
const group = categoryGroups.find(c => c.id === draggedGroupId);
|
||||
if (!group) {
|
||||
throw new Error(
|
||||
@@ -92,49 +95,11 @@ export function ExpenseGroupList({
|
||||
},
|
||||
onReorder: e => {
|
||||
const [key] = e.keys;
|
||||
const groupIdToMove = key as CategoryGroupEntity['id'];
|
||||
const groupToMove = categoryGroups.find(c => c.id === groupIdToMove);
|
||||
|
||||
if (!groupToMove) {
|
||||
throw new Error(
|
||||
`Internal error: category group with ID ${groupIdToMove} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetGroupId = e.target.key as CategoryEntity['id'];
|
||||
|
||||
if (e.target.dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveCategoryGroup({
|
||||
id: groupToMove.id,
|
||||
targetId: targetGroupId,
|
||||
}),
|
||||
);
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
const targetGroupIndex = categoryGroups.findIndex(
|
||||
c => c.id === targetGroupId,
|
||||
);
|
||||
|
||||
if (targetGroupIndex === -1) {
|
||||
throw new Error(
|
||||
`Internal error: category group with ID ${targetGroupId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextToTargetCategory = categoryGroups[targetGroupIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveCategoryGroup({
|
||||
id: groupToMove.id,
|
||||
// Due to the way `moveCategory` works, we use the category next to the
|
||||
// actual target category here because `moveCategory` always shoves the
|
||||
// category *before* the target category.
|
||||
// On the other hand, using `null` as `targetId` moves the category
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetCategory?.id || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
reorderCategoryGroup({
|
||||
id: key as CategoryGroupEntity['id'],
|
||||
targetId: e.target.key as CategoryGroupEntity['id'],
|
||||
dropPosition: e.target.dropPosition,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -174,3 +139,60 @@ export function ExpenseGroupList({
|
||||
</GridList>
|
||||
);
|
||||
}
|
||||
|
||||
function useReorderCategoryGroup() {
|
||||
const dispatch = useDispatch();
|
||||
const { list: categoryGroups } = useCategories();
|
||||
const reorderCategoryGroup = ({
|
||||
id,
|
||||
targetId,
|
||||
dropPosition,
|
||||
}: {
|
||||
id: CategoryGroupEntity['id'];
|
||||
targetId: CategoryGroupEntity['id'];
|
||||
dropPosition: 'on' | 'before' | 'after';
|
||||
}) => {
|
||||
const groupToMove = categoryGroups.find(c => c.id === id);
|
||||
|
||||
if (!groupToMove) {
|
||||
throw new Error(
|
||||
`Internal error: category group with ID ${id} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveCategoryGroup({
|
||||
id: groupToMove.id,
|
||||
targetId,
|
||||
}),
|
||||
);
|
||||
} else if (dropPosition === 'after') {
|
||||
const targetGroupIndex = categoryGroups.findIndex(c => c.id === targetId);
|
||||
|
||||
if (targetGroupIndex === -1) {
|
||||
throw new Error(
|
||||
`Internal error: category group with ID ${targetId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextToTargetCategory = categoryGroups[targetGroupIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveCategoryGroup({
|
||||
id: groupToMove.id,
|
||||
// Due to the way `moveCategory` works, we use the category next to the
|
||||
// actual target category here because `moveCategory` always shoves the
|
||||
// category *before* the target category.
|
||||
// On the other hand, using `null` as `targetId` moves the category
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetCategory?.id || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
reorderCategoryGroup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,15 +202,6 @@ 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,7 +3,6 @@ import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
Fragment,
|
||||
} from 'react';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
@@ -44,15 +43,10 @@ export function CellValue<
|
||||
const { fullSheetName } = useSheetName(binding);
|
||||
const sheetValue = useSheetValue(binding);
|
||||
|
||||
// Re-render when these value changes.
|
||||
const key = `${fullSheetName}|${sheetValue}`;
|
||||
return typeof children === 'function' ? (
|
||||
<Fragment key={key}>
|
||||
{children({ type, name: fullSheetName, value: sheetValue })}
|
||||
</Fragment>
|
||||
<>{children({ type, name: fullSheetName, value: sheetValue })}</>
|
||||
) : (
|
||||
<CellValueText
|
||||
key={key}
|
||||
type={type}
|
||||
name={fullSheetName}
|
||||
value={sheetValue}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import {
|
||||
type CategoryEntity,
|
||||
type CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useCategories } from './useCategories';
|
||||
import { useNavigate } from './useNavigate';
|
||||
|
||||
import {
|
||||
createCategory,
|
||||
createCategoryGroup,
|
||||
deleteCategory,
|
||||
deleteCategoryGroup,
|
||||
moveCategory,
|
||||
moveCategoryGroup,
|
||||
updateCategory,
|
||||
updateCategoryGroup,
|
||||
} from '@desktop-client/budget/budgetSlice';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
export function useCategoryMutations() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
|
||||
const categoryNameAlreadyExistsNotification = (
|
||||
name: CategoryEntity['name'],
|
||||
) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t(
|
||||
'Category "{{name}}" already exists in group (it may be hidden)',
|
||||
{ name },
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onSaveCategory = async (category: CategoryEntity) => {
|
||||
const { grouped: categoryGroups = [] } = await send('get-categories');
|
||||
|
||||
const group = categoryGroups.find(g => g.id === category.group);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCategories = group.categories ?? [];
|
||||
|
||||
const exists =
|
||||
groupCategories
|
||||
.filter(c => c.name.toUpperCase() === category.name.toUpperCase())
|
||||
.filter(c => (category.id === 'new' ? true : c.id !== category.id))
|
||||
.length > 0;
|
||||
|
||||
if (exists) {
|
||||
categoryNameAlreadyExistsNotification(category.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (category.id === 'new') {
|
||||
dispatch(
|
||||
createCategory({
|
||||
name: category.name,
|
||||
groupId: category.group,
|
||||
isIncome: !!category.is_income,
|
||||
isHidden: !!category.hidden,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(updateCategory({ category }));
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteCategory = async (id: CategoryEntity['id']) => {
|
||||
const mustTransfer = await send('must-category-transfer', { id });
|
||||
|
||||
if (mustTransfer) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-category-delete',
|
||||
options: {
|
||||
category: id,
|
||||
onDelete: transferCategory => {
|
||||
if (id !== transferCategory) {
|
||||
dispatch(
|
||||
deleteCategory({ id, transferId: transferCategory }),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(deleteCategory({ id }));
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveGroup = (group: CategoryGroupEntity) => {
|
||||
if (group.id === 'new') {
|
||||
dispatch(createCategoryGroup({ name: group.name }));
|
||||
} else {
|
||||
dispatch(updateCategoryGroup({ group }));
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteGroup = async (id: CategoryGroupEntity['id']) => {
|
||||
const group = categoryGroups.find(g => g.id === id);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCategories = group.categories ?? [];
|
||||
|
||||
let mustTransfer = false;
|
||||
for (const category of groupCategories) {
|
||||
if (await send('must-category-transfer', { id: category.id })) {
|
||||
mustTransfer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mustTransfer) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-category-delete',
|
||||
options: {
|
||||
group: id,
|
||||
onDelete: transferCategory => {
|
||||
dispatch(
|
||||
deleteCategoryGroup({ id, transferId: transferCategory }),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(deleteCategoryGroup({ id }));
|
||||
}
|
||||
};
|
||||
|
||||
const onShowActivity = (categoryId: CategoryEntity['id'], month: string) => {
|
||||
const filterConditions = [
|
||||
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
|
||||
{
|
||||
field: 'date',
|
||||
op: 'is',
|
||||
value: month,
|
||||
options: { month: true },
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
filterConditions,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onReorderCategory = async (sortInfo: {
|
||||
id: CategoryEntity['id'];
|
||||
groupId?: CategoryGroupEntity['id'];
|
||||
targetId: CategoryEntity['id'] | null;
|
||||
}) => {
|
||||
const { grouped: categoryGroups = [], list: categories = [] } =
|
||||
await send('get-categories');
|
||||
|
||||
const moveCandidate = categories.find(c => c.id === sortInfo.id);
|
||||
const group = categoryGroups.find(g => g.id === sortInfo.groupId);
|
||||
|
||||
if (!moveCandidate || !group) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCategories = group.categories ?? [];
|
||||
|
||||
const exists =
|
||||
groupCategories
|
||||
.filter(c => c.name.toUpperCase() === moveCandidate.name.toUpperCase())
|
||||
.filter(c => c.id !== moveCandidate.id).length > 0;
|
||||
|
||||
if (exists) {
|
||||
categoryNameAlreadyExistsNotification(moveCandidate.name);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: moveCandidate.id,
|
||||
groupId: group.id,
|
||||
targetId: sortInfo.targetId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onReorderGroup = async (sortInfo: {
|
||||
id: CategoryGroupEntity['id'];
|
||||
targetId: CategoryGroupEntity['id'] | null;
|
||||
}) => {
|
||||
dispatch(
|
||||
moveCategoryGroup({ id: sortInfo.id, targetId: sortInfo.targetId }),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
onSaveCategory,
|
||||
onDeleteCategory,
|
||||
onSaveGroup,
|
||||
onDeleteGroup,
|
||||
onShowActivity,
|
||||
onReorderCategory,
|
||||
onReorderGroup,
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,6 @@ 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,10 +4,7 @@ export type FeatureFlag =
|
||||
| 'actionTemplating'
|
||||
| 'formulaMode'
|
||||
| 'currency'
|
||||
| 'crossoverReport'
|
||||
| 'plugins'
|
||||
| 'forceReload'
|
||||
| 'budgetTableV2';
|
||||
| 'crossoverReport';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
[Experimental] Rewrite of desktop budget table
|
||||
6
upcoming-release-notes/6634.md
Normal file
6
upcoming-release-notes/6634.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
[Mobile] Fix drag and drop across category groups
|
||||
Reference in New Issue
Block a user