From c992e340ca696b7ee91ccffefde130bf3e57895c Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com> Date: Mon, 10 Jun 2024 13:16:27 -0700 Subject: [PATCH] Cover overbudgeted action + make balance movement menus only appear on relevant conditions (#2850) * Make balance movement menus only appear on relevant conditions * Release notes * Hide to be budgeted when covering overbudgeted * Fix typecheck error --- .../desktop-client/src/components/Modals.tsx | 4 +- .../budget/rollover/BalanceMenu.tsx | 24 ++++--- .../budget/rollover/BalanceMovementMenu.tsx | 2 +- .../components/budget/rollover/CoverMenu.tsx | 14 ++-- .../rollover/budgetsummary/ToBudget.tsx | 17 ++++- .../rollover/budgetsummary/ToBudgetMenu.tsx | 66 +++++++++++++++---- .../components/mobile/budget/BudgetTable.jsx | 2 +- .../src/components/modals/CoverModal.tsx | 27 +++----- .../modals/RolloverBudgetSummaryModal.tsx | 25 +++++-- .../modals/RolloverToBudgetMenuModal.tsx | 2 + .../loot-core/src/client/actions/queries.ts | 8 ++- .../src/client/state-types/modals.d.ts | 4 +- .../loot-core/src/server/budget/actions.ts | 14 ++++ packages/loot-core/src/server/budget/app.ts | 4 ++ .../src/server/budget/types/handlers.d.ts | 5 ++ upcoming-release-notes/2850.md | 6 ++ 16 files changed, 168 insertions(+), 56 deletions(-) create mode 100644 upcoming-release-notes/2850.md diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index d42fe09eb..cb814ec23 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -544,6 +544,7 @@ export function Modals() { <RolloverToBudgetMenuModal modalProps={modalProps} onTransfer={options.onTransfer} + onCover={options.onCover} onHoldBuffer={options.onHoldBuffer} onResetHoldBuffer={options.onResetHoldBuffer} /> @@ -596,8 +597,9 @@ export function Modals() { <CoverModal key={name} modalProps={modalProps} - categoryId={options.categoryId} + title={options.title} month={options.month} + showToBeBudgeted={options.showToBeBudgeted} onSubmit={options.onSubmit} /> ); diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx index 3ec3c5f0f..a0df8edb6 100644 --- a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx @@ -43,16 +43,14 @@ export function BalanceMenu({ } }} items={[ - { - name: 'transfer', - text: 'Transfer to another category', - }, - { - name: 'carryover', - text: carryover - ? 'Remove overspending rollover' - : 'Rollover overspending', - }, + ...(balance > 0 + ? [ + { + name: 'transfer', + text: 'Transfer to another category', + }, + ] + : []), ...(balance < 0 ? [ { @@ -61,6 +59,12 @@ export function BalanceMenu({ }, ] : []), + { + name: 'carryover', + text: carryover + ? 'Remove overspending rollover' + : 'Rollover overspending', + }, ]} /> ); diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx index 0957b7bc8..ad87c8be6 100644 --- a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx @@ -60,7 +60,7 @@ export function BalanceMovementMenu({ <CoverMenu onClose={onClose} onSubmit={fromCategoryId => { - onBudgetAction(month, 'cover', { + onBudgetAction(month, 'cover-overspending', { to: categoryId, from: fromCategoryId, }); diff --git a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx index d321357ad..553e28d4e 100644 --- a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx @@ -8,15 +8,21 @@ import { View } from '../../common/View'; import { addToBeBudgetedGroup } from '../util'; type CoverMenuProps = { + showToBeBudgeted?: boolean; onSubmit: (categoryId: string) => void; onClose: () => void; }; -export function CoverMenu({ onSubmit, onClose }: CoverMenuProps) { +export function CoverMenu({ + showToBeBudgeted = true, + onSubmit, + onClose, +}: CoverMenuProps) { const { grouped: originalCategoryGroups } = useCategories(); - const categoryGroups = addToBeBudgetedGroup( - originalCategoryGroups.filter(g => !g.is_income), - ); + let categoryGroups = originalCategoryGroups.filter(g => !g.is_income); + categoryGroups = showToBeBudgeted + ? addToBeBudgetedGroup(categoryGroups) + : categoryGroups; const [categoryId, setCategoryId] = useState<string | null>(null); function submit() { diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx index 8d5234b1c..f63f9c18e 100644 --- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx +++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx @@ -6,6 +6,7 @@ import { type CSSProperties } from '../../../../style'; import { Popover } from '../../../common/Popover'; import { View } from '../../../common/View'; import { useSheetValue } from '../../../spreadsheet/useSheetValue'; +import { CoverMenu } from '../CoverMenu'; import { HoldMenu } from '../HoldMenu'; import { TransferMenu } from '../TransferMenu'; @@ -55,6 +56,7 @@ export function ToBudget({ {menuOpen === 'actions' && ( <ToBudgetMenu onTransfer={() => setMenuOpen('transfer')} + onCover={() => setMenuOpen('cover')} onHoldBuffer={() => setMenuOpen('buffer')} onResetHoldBuffer={() => { onBudgetAction(month, 'reset-hold'); @@ -74,10 +76,21 @@ export function ToBudget({ <TransferMenu initialAmount={availableValue} onClose={() => setMenuOpen(null)} - onSubmit={(amount, category) => { + onSubmit={(amount, categoryId) => { onBudgetAction(month, 'transfer-available', { amount, - category, + category: categoryId, + }); + }} + /> + )} + {menuOpen === 'cover' && ( + <CoverMenu + showToBeBudgeted={false} + onClose={() => setMenuOpen(null)} + onSubmit={categoryId => { + onBudgetAction(month, 'cover-overbudgeted', { + category: categoryId, }); }} /> diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx index 95cf54a8f..37ddf5507 100644 --- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx @@ -1,21 +1,59 @@ import React, { type ComponentPropsWithoutRef } from 'react'; +import { rolloverBudget } from 'loot-core/client/queries'; + import { Menu } from '../../../common/Menu'; +import { useSheetValue } from '../../../spreadsheet/useSheetValue'; type ToBudgetMenuProps = Omit< ComponentPropsWithoutRef<typeof Menu>, 'onMenuSelect' | 'items' > & { onTransfer: () => void; + onCover: () => void; onHoldBuffer: () => void; onResetHoldBuffer: () => void; }; export function ToBudgetMenu({ onTransfer, + onCover, onHoldBuffer, onResetHoldBuffer, ...props }: ToBudgetMenuProps) { + const toBudget = useSheetValue(rolloverBudget.toBudget); + const forNextMonth = useSheetValue(rolloverBudget.forNextMonth); + const items = [ + ...(toBudget > 0 + ? [ + { + name: 'transfer', + text: 'Move to a category', + }, + { + name: 'buffer', + text: 'Hold for next month', + }, + ] + : []), + ...(toBudget < 0 + ? [ + { + name: 'cover', + text: 'Cover from a category', + }, + ] + : []), + ...(forNextMonth > 0 + ? [ + { + name: 'reset-buffer', + text: 'Reset next month’s buffer', + }, + ] + : []), + ]; + return ( <Menu {...props} @@ -24,6 +62,9 @@ export function ToBudgetMenu({ case 'transfer': onTransfer?.(); break; + case 'cover': + onCover?.(); + break; case 'buffer': onHoldBuffer?.(); break; @@ -34,20 +75,17 @@ export function ToBudgetMenu({ throw new Error(`Unrecognized menu option: ${name}`); } }} - items={[ - { - name: 'transfer', - text: 'Move to a category', - }, - { - name: 'buffer', - text: 'Hold for next month', - }, - { - name: 'reset-buffer', - text: 'Reset next month’s buffer', - }, - ]} + items={ + items.length > 0 + ? items + : [ + { + name: 'none', + text: 'No actions available', + disabled: true, + }, + ] + } /> ); } diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx index 09a120fc1..9a00465f3 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx @@ -373,7 +373,7 @@ const ExpenseCategory = memo(function ExpenseCategory({ categoryId: category.id, month, onSubmit: fromCategoryId => { - onBudgetAction(month, 'cover', { + onBudgetAction(month, 'cover-overspending', { to: category.id, from: fromCategoryId, }); diff --git a/packages/desktop-client/src/components/modals/CoverModal.tsx b/packages/desktop-client/src/components/modals/CoverModal.tsx index 81386d44f..d268472a9 100644 --- a/packages/desktop-client/src/components/modals/CoverModal.tsx +++ b/packages/desktop-client/src/components/modals/CoverModal.tsx @@ -15,25 +15,28 @@ import { type CommonModalProps } from '../Modals'; type CoverModalProps = { modalProps: CommonModalProps; - categoryId: string; + title: string; month: string; + showToBeBudgeted?: boolean; onSubmit: (categoryId: string) => void; }; export function CoverModal({ modalProps, - categoryId, + title, month, + showToBeBudgeted = true, onSubmit, }: CoverModalProps) { const { grouped: originalCategoryGroups } = useCategories(); const [categoryGroups, categories] = useMemo(() => { - const expenseGroups = addToBeBudgetedGroup( - originalCategoryGroups.filter(g => !g.is_income), - ); + let expenseGroups = originalCategoryGroups.filter(g => !g.is_income); + expenseGroups = showToBeBudgeted + ? addToBeBudgetedGroup(expenseGroups) + : expenseGroups; const expenseCategories = expenseGroups.flatMap(g => g.categories || []); return [expenseGroups, expenseCategories]; - }, [originalCategoryGroups]); + }, [originalCategoryGroups, showToBeBudgeted]); const [fromCategoryId, setFromCategoryId] = useState<string | null>(null); const dispatch = useDispatch(); @@ -67,19 +70,9 @@ export function CoverModal({ }, [initialMount, onCategoryClick]); const fromCategory = categories.find(c => c.id === fromCategoryId); - const category = categories.find(c => c.id === categoryId); - - if (!category) { - return null; - } return ( - <Modal - title={category.name} - showHeader - focusAfterClose={false} - {...modalProps} - > + <Modal title={title} showHeader focusAfterClose={false} {...modalProps}> <View> <FieldLabel title="Cover from category:" /> <TapField value={fromCategory?.name} onClick={onCategoryClick} /> diff --git a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx index 1970a1912..a2bba5ee9 100644 --- a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx +++ b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx @@ -31,14 +31,14 @@ export function RolloverBudgetSummaryModal({ value: 0, }); - const openTransferModal = () => { + const openTransferAvailableModal = () => { dispatch( pushModal('transfer', { title: 'Transfer: To Budget', month, amount: sheetValue, onSubmit: (amount, toCategoryId) => { - onBudgetAction?.(month, 'transfer-available', { + onBudgetAction(month, 'transfer-available', { amount, month, category: toCategoryId, @@ -49,6 +49,22 @@ export function RolloverBudgetSummaryModal({ ); }; + const openCoverOverbudgetedModal = () => { + dispatch( + pushModal('cover', { + title: 'Cover: Overbudgeted', + month, + showToBeBudgeted: false, + onSubmit: categoryId => { + onBudgetAction(month, 'cover-overbudgeted', { + category: categoryId, + }); + dispatch(collapseModals('cover')); + }, + }), + ); + }; + const onHoldBuffer = () => { dispatch( pushModal('hold-buffer', { @@ -62,7 +78,7 @@ export function RolloverBudgetSummaryModal({ }; const onResetHoldBuffer = () => { - onBudgetAction?.(month, 'reset-hold'); + onBudgetAction(month, 'reset-hold'); modalProps.onClose(); }; @@ -70,7 +86,8 @@ export function RolloverBudgetSummaryModal({ dispatch( pushModal('rollover-summary-to-budget-menu', { month, - onTransfer: openTransferModal, + onTransfer: openTransferAvailableModal, + onCover: openCoverOverbudgetedModal, onResetHoldBuffer, onHoldBuffer, }), diff --git a/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx b/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx index da1c28a1e..c94e247be 100644 --- a/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx +++ b/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx @@ -14,6 +14,7 @@ type RolloverToBudgetMenuModalProps = ComponentPropsWithoutRef< export function RolloverToBudgetMenuModal({ modalProps, onTransfer, + onCover, onHoldBuffer, onResetHoldBuffer, }: RolloverToBudgetMenuModalProps) { @@ -29,6 +30,7 @@ export function RolloverToBudgetMenuModal({ <ToBudgetMenu getItemStyle={() => defaultMenuItemStyle} onTransfer={onTransfer} + onCover={onCover} onHoldBuffer={onHoldBuffer} onResetHoldBuffer={onResetHoldBuffer} /> diff --git a/packages/loot-core/src/client/actions/queries.ts b/packages/loot-core/src/client/actions/queries.ts index 692d59682..badb36f37 100644 --- a/packages/loot-core/src/client/actions/queries.ts +++ b/packages/loot-core/src/client/actions/queries.ts @@ -53,7 +53,7 @@ export function applyBudgetAction(month, type, args) { case 'reset-hold': await send('budget/reset-hold', { month }); break; - case 'cover': + case 'cover-overspending': await send('budget/cover-overspending', { month, to: args.to, @@ -67,6 +67,12 @@ export function applyBudgetAction(month, type, args) { category: args.category, }); break; + case 'cover-overbudgeted': + await send('budget/cover-overbudgeted', { + month, + category: args.category, + }); + break; case 'transfer-category': await send('budget/transfer-category', { month, diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index 4e607993c..941671c32 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -201,6 +201,7 @@ type FinanceModals = { 'rollover-summary-to-budget-menu': { month: string; onTransfer: () => void; + onCover: () => void; onHoldBuffer: () => void; onResetHoldBuffer: () => void; }; @@ -217,8 +218,9 @@ type FinanceModals = { showToBeBudgeted?: boolean; }; cover: { - categoryId: string; + title: string; month: string; + showToBeBudgeted?: boolean; onSubmit: (fromCategoryId: string) => void; }; 'hold-buffer': { diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index 6ddffc06e..2b02a4527 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -373,6 +373,20 @@ export async function transferAvailable({ await setBudget({ category, month, amount: budgeted + amount }); } +export async function coverOverbudgeted({ + month, + category, +}: { + month: string; + category: string; +}): Promise<void> { + const sheetName = monthUtils.sheetForMonth(month); + const toBudget = await getSheetValue(sheetName, 'to-budget'); + + const categoryBudget = await getSheetValue(sheetName, 'budget-' + category); + await setBudget({ category, month, amount: categoryBudget + toBudget }); +} + export async function transferCategory({ month, amount, diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index 7cca22aa5..a1816665d 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -54,6 +54,10 @@ app.method( 'budget/transfer-available', mutator(undoable(actions.transferAvailable)), ); +app.method( + 'budget/cover-overbudgeted', + mutator(undoable(actions.coverOverbudgeted)), +); app.method( 'budget/transfer-category', mutator(undoable(actions.transferCategory)), diff --git a/packages/loot-core/src/server/budget/types/handlers.d.ts b/packages/loot-core/src/server/budget/types/handlers.d.ts index 66c2f72c7..b5d657436 100644 --- a/packages/loot-core/src/server/budget/types/handlers.d.ts +++ b/packages/loot-core/src/server/budget/types/handlers.d.ts @@ -46,6 +46,11 @@ export interface BudgetHandlers { category: string; }) => Promise<void>; + 'budget/cover-overbudgeted': (arg: { + month: string; + category: string; + }) => Promise<void>; + 'budget/transfer-category': (arg: { month: string; amount: number; diff --git a/upcoming-release-notes/2850.md b/upcoming-release-notes/2850.md new file mode 100644 index 000000000..cc1805e68 --- /dev/null +++ b/upcoming-release-notes/2850.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [joel-jeremy] +--- + +Cover overbudgeted action + make balance movement menus only appear on relevant conditions e.g. transfer to another category menu only when there is a leftover balance. -- GitLab