diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index d42fe09eb87a8a970f5d2cd6abe1321dc83212c0..cb814ec23d2f44f4ad7ab23bd00a5012811d6cea 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 3ec3c5f0f6c92d706cfe4c0c3427278f7db3da24..a0df8edb60f96880f03ffa46dc6972570d124b04 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 0957b7bc827088147d488fa580fa37254fb796bb..ad87c8be6eb59ce7d43bdb407c0b0ab496295b15 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 d321357adfdbbc95d16f6f5cf64f0a2322a2ca5c..553e28d4ee1d471d0d70530938978d8548bb742b 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 8d5234b1c5f602b5c032ab893af695bffc1b449c..f63f9c18e830cf5dc4ead5b33dd906acdc032d02 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 95cf54a8f14c7cb66634b76059f1c6bab2e1acc4..37ddf5507b4f9ec286073af302e40074da67c7bb 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 09a120fc188a140c7763bb4d574c40dceee8edb3..9a00465f353118a3d3a5847bee7314eae8037e5a 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 81386d44fb7c01e840c2e5f850698553a906a2bb..d268472a9c168b6c49e1b44bd99258d00a226e7f 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 1970a1912a2bdb4e6f1cd6f928d36aa9ffbf3c5c..a2bba5ee9fc1098ee62bc34307dcd18393a1e7c4 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 da1c28a1e2f4ca44a5820cb9a58164028236ddd5..c94e247be0e3423e28dbadc3be499911c00568fe 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 692d5968224ff76dd256d911f328e3f18b90b2cd..badb36f37cbe53bccb239025f51af356854a3fce 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 4e607993c8c6a0ef31c1bff2170018248ff26120..941671c3266a8f317d1062669c904603b1c7906f 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 6ddffc06e46e94a37eb5e1547d76f385228d35de..2b02a4527c7ad618e8080db44a58df417355655a 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 7cca22aa5a57e4b96007ab97465d95710d7fb738..a1816665d366a1c2e20e9f73083eb466568d92f1 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 66c2f72c731da700c210696e16b8ccf5f66ff0f5..b5d6574369ac60104854d56f7022229dfaafa597 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 0000000000000000000000000000000000000000..cc1805e68c0bacb0ed14cc0d1ed77ca4c1cd0d58 --- /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.