From e6bf6da3814d451a534f5da2138a1881bf79cd66 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com> Date: Thu, 19 Sep 2024 16:13:19 -0700 Subject: [PATCH] Undoable auto transfer notes + auto notes for cover (#3411) * Undo auto transfer notes + auto notes for cover * Release notes * Fix notes * Fix notes undo * Do not show clicked category on transfer or cover menus * Fix typecheck error * typecheck * Fix removeCategoriesFromGroups --- .../desktop-client/src/components/Modals.tsx | 3 +- .../budget/rollover/BalanceMovementMenu.tsx | 68 +----------- .../components/budget/rollover/CoverMenu.tsx | 55 ++++------ .../budget/rollover/TransferMenu.tsx | 40 ++++--- .../src/components/budget/util.ts | 22 +++- .../components/mobile/budget/BudgetTable.jsx | 3 +- .../src/components/modals/CoverModal.tsx | 60 ++++------- .../src/components/modals/TransferModal.tsx | 30 ++++-- .../src/client/state-types/modals.d.ts | 2 + .../loot-core/src/server/budget/actions.ts | 101 ++++++++++++++++-- packages/loot-core/src/server/db/index.ts | 55 ++++++---- packages/loot-core/src/server/notes/app.ts | 7 +- upcoming-release-notes/3411.md | 6 ++ 13 files changed, 252 insertions(+), 200 deletions(-) create mode 100644 upcoming-release-notes/3411.md diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 88aae48cf..7af9f343a 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -497,6 +497,7 @@ export function Modals() { <TransferModal key={name} title={options.title} + categoryId={options.categoryId} month={options.month} amount={options.amount} onSubmit={options.onSubmit} @@ -509,9 +510,9 @@ export function Modals() { <CoverModal key={name} title={options.title} + categoryId={options.categoryId} month={options.month} showToBeBudgeted={options.showToBeBudgeted} - category={options.category} onSubmit={options.onSubmit} /> ); diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx index 672a25521..02fe80731 100644 --- a/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/BalanceMovementMenu.tsx @@ -1,15 +1,6 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useState } from 'react'; -import { runQuery } from 'loot-core/client/query-helpers'; -import { send } from 'loot-core/platform/client/fetch'; -import { q } from 'loot-core/shared/query'; import { rolloverBudget } from 'loot-core/src/client/queries'; -import * as monthUtils from 'loot-core/src/shared/months'; -import { groupById, integerToCurrency } from 'loot-core/src/shared/util'; -import { type CategoryEntity } from 'loot-core/types/models'; -import { type WithRequired } from 'loot-core/types/util'; - -import { useCategories } from '../../../hooks/useCategories'; import { BalanceMenu } from './BalanceMenu'; import { CoverMenu } from './CoverMenu'; @@ -34,8 +25,6 @@ export function BalanceMovementMenu({ ); const [menu, setMenu] = useState('menu'); - const { addBudgetTransferNotes } = useBudgetTransferNotes({ month }); - return ( <> {menu === 'menu' && ( @@ -55,6 +44,7 @@ export function BalanceMovementMenu({ {menu === 'transfer' && ( <TransferMenu + categoryId={categoryId} initialAmount={catBalance} showToBeBudgeted={true} onClose={onClose} @@ -64,18 +54,13 @@ export function BalanceMovementMenu({ from: categoryId, to: toCategoryId, }); - addBudgetTransferNotes({ - fromCategoryId: categoryId, - toCategoryId, - amount, - }); }} /> )} {menu === 'cover' && ( <CoverMenu - category={categoryId} + categoryId={categoryId} onClose={onClose} onSubmit={fromCategoryId => { onBudgetAction(month, 'cover-overspending', { @@ -88,50 +73,3 @@ export function BalanceMovementMenu({ </> ); } - -const useBudgetTransferNotes = ({ month }: { month: string }) => { - const { list: categories } = useCategories(); - const categoriesById = useMemo(() => { - return groupById(categories as WithRequired<CategoryEntity, 'id'>[]); - }, [categories]); - - const getNotes = async (id: string) => { - const { data: notes } = await runQuery( - q('notes').filter({ id }).select('note'), - ); - return (notes && notes[0]?.note) ?? ''; - }; - - const addNewLine = (notes?: string) => `${notes}${notes && '\n'}`; - - const addBudgetTransferNotes = useCallback( - async ({ - fromCategoryId, - toCategoryId, - amount, - }: { - fromCategoryId: Required<CategoryEntity['id']>; - toCategoryId: Required<CategoryEntity['id']>; - amount: number; - }) => { - const displayAmount = integerToCurrency(amount); - - const monthBudgetNotesId = `budget-${month}`; - const existingMonthBudgetNotes = addNewLine( - await getNotes(monthBudgetNotesId), - ); - - const displayDay = monthUtils.format(monthUtils.currentDate(), 'MMMM dd'); - const fromCategoryName = categoriesById[fromCategoryId || ''].name; - const toCategoryName = categoriesById[toCategoryId || ''].name; - - await send('notes-save', { - id: monthBudgetNotesId, - note: `${existingMonthBudgetNotes}- Reassigned ${displayAmount} from ${fromCategoryName} to ${toCategoryName} on ${displayDay}`, - }); - }, - [categoriesById, month], - ); - - return { addBudgetTransferNotes }; -}; diff --git a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx index 1fec28b1e..6a126b389 100644 --- a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx @@ -1,61 +1,44 @@ import React, { useMemo, useState } from 'react'; -import { - type CategoryGroupEntity, - type CategoryEntity, -} from 'loot-core/src/types/models'; +import { type CategoryEntity } from 'loot-core/src/types/models'; import { useCategories } from '../../../hooks/useCategories'; import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete'; import { Button } from '../../common/Button2'; import { InitialFocus } from '../../common/InitialFocus'; import { View } from '../../common/View'; -import { addToBeBudgetedGroup } from '../util'; - -function removeSelectedCategory( - categoryGroups: CategoryGroupEntity[], - category?: CategoryEntity['id'], -) { - if (!category) return categoryGroups; - - return categoryGroups - .map(group => ({ - ...group, - categories: group.categories?.filter(cat => cat.id !== category), - })) - .filter(group => group.categories?.length); -} +import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util'; type CoverMenuProps = { showToBeBudgeted?: boolean; - category?: CategoryEntity['id']; - onSubmit: (categoryId: string) => void; + categoryId?: CategoryEntity['id']; + onSubmit: (categoryId: CategoryEntity['id']) => void; onClose: () => void; }; export function CoverMenu({ showToBeBudgeted = true, - category, + categoryId, onSubmit, onClose, }: CoverMenuProps) { const { grouped: originalCategoryGroups } = useCategories(); - const expenseGroups = originalCategoryGroups.filter(g => !g.is_income); - const categoryGroups = showToBeBudgeted - ? addToBeBudgetedGroup(expenseGroups) - : expenseGroups; + const [fromCategoryId, setFromCategoryId] = useState<string | null>(null); - const [categoryId, setCategoryId] = useState<string | null>(null); - - const filteredCategoryGroups = useMemo( - () => removeSelectedCategory(categoryGroups, category), - [categoryGroups, category], - ); + const filteredCategoryGroups = useMemo(() => { + const expenseGroups = originalCategoryGroups.filter(g => !g.is_income); + const categoryGroups = showToBeBudgeted + ? addToBeBudgetedGroup(expenseGroups) + : expenseGroups; + return categoryId + ? removeCategoriesFromGroups(categoryGroups, categoryId) + : categoryGroups; + }, [categoryId, showToBeBudgeted, originalCategoryGroups]); function submit() { - if (categoryId) { - onSubmit(categoryId); + if (fromCategoryId) { + onSubmit(fromCategoryId); } onClose(); } @@ -67,9 +50,9 @@ export function CoverMenu({ {node => ( <CategoryAutocomplete categoryGroups={filteredCategoryGroups} - value={categoryGroups.find(g => g.id === categoryId) ?? null} + value={null} openOnFocus={true} - onSelect={(id: string | undefined) => setCategoryId(id || null)} + onSelect={(id: string | undefined) => setFromCategoryId(id || null)} inputProps={{ inputRef: node, onEnter: event => !event.defaultPrevented && submit(), diff --git a/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx b/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx index 76df5c655..f304eab3d 100644 --- a/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx @@ -1,7 +1,8 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { evalArithmetic } from 'loot-core/src/shared/arithmetic'; import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util'; +import { type CategoryEntity } from 'loot-core/types/models'; import { useCategories } from '../../../hooks/useCategories'; import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete'; @@ -9,32 +10,39 @@ import { Button } from '../../common/Button2'; import { InitialFocus } from '../../common/InitialFocus'; import { Input } from '../../common/Input'; import { View } from '../../common/View'; -import { addToBeBudgetedGroup } from '../util'; +import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util'; type TransferMenuProps = { + categoryId?: CategoryEntity['id']; initialAmount?: number; showToBeBudgeted?: boolean; - onSubmit: (amount: number, categoryId: string) => void; + onSubmit: (amount: number, categoryId: CategoryEntity['id']) => void; onClose: () => void; }; export function TransferMenu({ + categoryId, initialAmount = 0, showToBeBudgeted, onSubmit, onClose, }: TransferMenuProps) { const { grouped: originalCategoryGroups } = useCategories(); - const filteredCategoryGroups = originalCategoryGroups.filter( - g => !g.is_income, - ); - const categoryGroups = showToBeBudgeted - ? addToBeBudgetedGroup(filteredCategoryGroups) - : filteredCategoryGroups; + const filteredCategoryGroups = useMemo(() => { + const expenseCategoryGroups = originalCategoryGroups.filter( + g => !g.is_income, + ); + const categoryGroups = showToBeBudgeted + ? addToBeBudgetedGroup(expenseCategoryGroups) + : expenseCategoryGroups; + return categoryId + ? removeCategoriesFromGroups(categoryGroups, categoryId) + : categoryGroups; + }, [originalCategoryGroups, categoryId, showToBeBudgeted]); const _initialAmount = integerToCurrency(Math.max(initialAmount, 0)); const [amount, setAmount] = useState<string | null>(null); - const [categoryId, setCategoryId] = useState<string | null>(null); + const [toCategoryId, setToCategoryId] = useState<string | null>(null); const _onSubmit = (newAmount: string | null, categoryId: string | null) => { const parsedAmount = evalArithmetic(newAmount || ''); @@ -53,20 +61,20 @@ export function TransferMenu({ <Input defaultValue={_initialAmount} onUpdate={value => setAmount(value)} - onEnter={() => _onSubmit(amount, categoryId)} + onEnter={() => _onSubmit(amount, toCategoryId)} /> </InitialFocus> </View> <View style={{ margin: '10px 0 5px 0' }}>To:</View> <CategoryAutocomplete - categoryGroups={categoryGroups} - value={categoryGroups.find(g => g.id === categoryId) ?? null} + categoryGroups={filteredCategoryGroups} + value={null} openOnFocus={true} - onSelect={(id: string | undefined) => setCategoryId(id || null)} + onSelect={(id: string | undefined) => setToCategoryId(id || null)} inputProps={{ onEnter: event => - !event.defaultPrevented && _onSubmit(amount, categoryId), + !event.defaultPrevented && _onSubmit(amount, toCategoryId), placeholder: '(none)', }} showHiddenCategories={true} @@ -85,7 +93,7 @@ export function TransferMenu({ paddingTop: 3, paddingBottom: 3, }} - onPress={() => _onSubmit(amount, categoryId)} + onPress={() => _onSubmit(amount, toCategoryId)} > Transfer </Button> diff --git a/packages/desktop-client/src/components/budget/util.ts b/packages/desktop-client/src/components/budget/util.ts index c8f7c6185..31472c980 100644 --- a/packages/desktop-client/src/components/budget/util.ts +++ b/packages/desktop-client/src/components/budget/util.ts @@ -3,7 +3,10 @@ import { type useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { type Handlers } from 'loot-core/src/types/handlers'; -import { type CategoryGroupEntity } from 'loot-core/src/types/models'; +import { + type CategoryEntity, + type CategoryGroupEntity, +} from 'loot-core/src/types/models'; import { type SyncedPrefs } from 'loot-core/src/types/prefs'; import { type CSSProperties, styles, theme } from '../../style'; @@ -32,6 +35,23 @@ export function addToBeBudgetedGroup(groups: CategoryGroupEntity[]) { ]; } +export function removeCategoriesFromGroups( + categoryGroups: CategoryGroupEntity[], + ...categoryIds: CategoryEntity['id'][] +) { + if (!categoryIds || categoryIds.length === 0) return categoryGroups; + + const categoryIdsSet = new Set(categoryIds); + + return categoryGroups + .map(group => ({ + ...group, + categories: + group.categories?.filter(cat => !categoryIdsSet.has(cat.id)) ?? [], + })) + .filter(group => group.categories?.length); +} + export function separateGroups(categoryGroups: CategoryGroupEntity[]) { return [ categoryGroups.filter(g => !g.is_income), diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx index 0bee9e2d0..c9f1ad4b2 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx @@ -420,6 +420,7 @@ const ExpenseCategory = memo(function ExpenseCategory({ dispatch( pushModal('transfer', { title: category.name, + categoryId: category.id, month, amount: catBalance, onSubmit: (amount, toCategoryId) => { @@ -453,7 +454,7 @@ const ExpenseCategory = memo(function ExpenseCategory({ pushModal('cover', { title: category.name, month, - category: category.id, + categoryId: category.id, onSubmit: fromCategoryId => { onBudgetAction(month, 'cover-overspending', { to: category.id, diff --git a/packages/desktop-client/src/components/modals/CoverModal.tsx b/packages/desktop-client/src/components/modals/CoverModal.tsx index a604fae23..49d758bb3 100644 --- a/packages/desktop-client/src/components/modals/CoverModal.tsx +++ b/packages/desktop-client/src/components/modals/CoverModal.tsx @@ -2,66 +2,48 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import { pushModal } from 'loot-core/client/actions'; -import { - type CategoryGroupEntity, - type CategoryEntity, -} from 'loot-core/src/types/models'; +import { type CategoryEntity } from 'loot-core/src/types/models'; import { useCategories } from '../../hooks/useCategories'; import { styles } from '../../style'; -import { addToBeBudgetedGroup } from '../budget/util'; +import { + addToBeBudgetedGroup, + removeCategoriesFromGroups, +} from '../budget/util'; import { Button } from '../common/Button2'; import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal'; import { View } from '../common/View'; import { FieldLabel, TapField } from '../mobile/MobileForms'; -function removeSelectedCategory( - categoryGroups: CategoryGroupEntity[], - category?: CategoryEntity['id'], -) { - if (!category) return categoryGroups; - - return categoryGroups - .map(group => ({ - ...group, - categories: group.categories?.filter(cat => cat.id !== category), - })) - .filter(group => group.categories?.length); -} - type CoverModalProps = { title: string; + categoryId?: CategoryEntity['id']; month: string; showToBeBudgeted?: boolean; - category?: CategoryEntity['id']; - onSubmit: (categoryId: string) => void; + onSubmit: (categoryId: CategoryEntity['id']) => void; }; export function CoverModal({ title, + categoryId, month, showToBeBudgeted = true, - category, onSubmit, }: CoverModalProps) { const { grouped: originalCategoryGroups } = useCategories(); const [categoryGroups, categories] = useMemo(() => { - const filteredCategoryGroups = originalCategoryGroups.filter( - g => !g.is_income, + const expenseGroups = originalCategoryGroups.filter(g => !g.is_income); + const categoryGroups = showToBeBudgeted + ? addToBeBudgetedGroup(expenseGroups) + : expenseGroups; + const filteredCategoryGroups = categoryId + ? removeCategoriesFromGroups(categoryGroups, categoryId) + : categoryGroups; + const filteredCategoryies = filteredCategoryGroups.flatMap( + g => g.categories || [], ); - - const expenseGroups = showToBeBudgeted - ? addToBeBudgetedGroup(filteredCategoryGroups) - : filteredCategoryGroups; - - const expenseCategories = expenseGroups.flatMap(g => g.categories || []); - return [expenseGroups, expenseCategories]; - }, [originalCategoryGroups, showToBeBudgeted]); - - const filteredCategoryGroups = useMemo( - () => removeSelectedCategory(categoryGroups, category), - [categoryGroups, category], - ); + return [filteredCategoryGroups, filteredCategoryies]; + }, [categoryId, originalCategoryGroups, showToBeBudgeted]); const [fromCategoryId, setFromCategoryId] = useState<string | null>(null); const dispatch = useDispatch(); @@ -69,14 +51,14 @@ export function CoverModal({ const onCategoryClick = useCallback(() => { dispatch( pushModal('category-autocomplete', { - categoryGroups: filteredCategoryGroups, + categoryGroups, month, onSelect: categoryId => { setFromCategoryId(categoryId); }, }), ); - }, [filteredCategoryGroups, dispatch, month]); + }, [categoryGroups, dispatch, month]); const _onSubmit = (categoryId: string | null) => { if (categoryId) { diff --git a/packages/desktop-client/src/components/modals/TransferModal.tsx b/packages/desktop-client/src/components/modals/TransferModal.tsx index 190d24001..18656bc8e 100644 --- a/packages/desktop-client/src/components/modals/TransferModal.tsx +++ b/packages/desktop-client/src/components/modals/TransferModal.tsx @@ -2,10 +2,14 @@ import React, { useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import { pushModal } from 'loot-core/client/actions'; +import { type CategoryEntity } from 'loot-core/types/models'; import { useCategories } from '../../hooks/useCategories'; import { styles } from '../../style'; -import { addToBeBudgetedGroup } from '../budget/util'; +import { + addToBeBudgetedGroup, + removeCategoriesFromGroups, +} from '../budget/util'; import { Button } from '../common/Button2'; import { InitialFocus } from '../common/InitialFocus'; import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal'; @@ -15,14 +19,16 @@ import { AmountInput } from '../util/AmountInput'; type TransferModalProps = { title: string; + categoryId?: CategoryEntity['id']; month: string; amount: number; showToBeBudgeted: boolean; - onSubmit: (amount: number, toCategoryId: string) => void; + onSubmit: (amount: number, toCategoryId: CategoryEntity['id']) => void; }; export function TransferModal({ title, + categoryId, month, amount: initialAmount, showToBeBudgeted, @@ -30,15 +36,19 @@ export function TransferModal({ }: TransferModalProps) { const { grouped: originalCategoryGroups } = useCategories(); const [categoryGroups, categories] = useMemo(() => { - const filteredCategoryGroups = originalCategoryGroups.filter( - g => !g.is_income, + const expenseGroups = originalCategoryGroups.filter(g => !g.is_income); + const categoryGroups = showToBeBudgeted + ? addToBeBudgetedGroup(expenseGroups) + : expenseGroups; + + const filteredCategoryGroups = categoryId + ? removeCategoriesFromGroups(categoryGroups, categoryId) + : categoryGroups; + const filteredCategories = filteredCategoryGroups.flatMap( + g => g.categories || [], ); - const expenseGroups = showToBeBudgeted - ? addToBeBudgetedGroup(filteredCategoryGroups) - : filteredCategoryGroups; - const expenseCategories = expenseGroups.flatMap(g => g.categories || []); - return [expenseGroups, expenseCategories]; - }, [originalCategoryGroups, showToBeBudgeted]); + return [filteredCategoryGroups, filteredCategories]; + }, [categoryId, originalCategoryGroups, showToBeBudgeted]); const [amount, setAmount] = useState<number>(0); const [toCategoryId, setToCategoryId] = useState<string | null>(null); 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 1b7870a5a..ef8c148e4 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -225,6 +225,7 @@ type FinanceModals = { }; transfer: { title: string; + categoryId?: CategoryEntity['id']; month: string; amount: number; onSubmit: (amount: number, toCategoryId: string) => void; @@ -232,6 +233,7 @@ type FinanceModals = { }; cover: { title: string; + categoryId?: CategoryEntity['id']; month: string; showToBeBudgeted?: boolean; onSubmit: (fromCategoryId: string) => void; diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index 28b803e72..0d9950553 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -1,6 +1,6 @@ // @ts-strict-ignore import * as monthUtils from '../../shared/months'; -import { safeNumber } from '../../shared/util'; +import { integerToCurrency, safeNumber } from '../../shared/util'; import * as db from '../db'; import * as sheet from '../sheet'; import { batchMessages } from '../sync'; @@ -371,7 +371,20 @@ export async function coverOverspending({ }); } - await setBudget({ category: to, month, amount: toBudgeted + amountCovered }); + await batchMessages(async () => { + await setBudget({ + category: to, + month, + amount: toBudgeted + amountCovered, + }); + + await addMovementNotes({ + month, + amount: amountCovered, + to, + from, + }); + }); } export async function transferAvailable({ @@ -402,7 +415,17 @@ export async function coverOverbudgeted({ const toBudget = await getSheetValue(sheetName, 'to-budget'); const categoryBudget = await getSheetValue(sheetName, 'budget-' + category); - await setBudget({ category, month, amount: categoryBudget + toBudget }); + + await batchMessages(async () => { + await setBudget({ category, month, amount: categoryBudget + toBudget }); + + await addMovementNotes({ + month, + amount: -toBudget, + from: category, + to: 'overbudgeted', + }); + }); } export async function transferCategory({ @@ -419,14 +442,23 @@ export async function transferCategory({ const sheetName = monthUtils.sheetForMonth(month); const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from); - await setBudget({ category: from, month, amount: fromBudgeted - amount }); + await batchMessages(async () => { + await setBudget({ category: from, month, amount: fromBudgeted - amount }); - // If we are simply moving it back into available cash to budget, - // don't do anything else - if (to !== 'to-be-budgeted') { - const toBudgeted = await getSheetValue(sheetName, 'budget-' + to); - await setBudget({ category: to, month, amount: toBudgeted + amount }); - } + // If we are simply moving it back into available cash to budget, + // don't do anything else + if (to !== 'to-be-budgeted') { + const toBudgeted = await getSheetValue(sheetName, 'budget-' + to); + await setBudget({ category: to, month, amount: toBudgeted + amount }); + } + + await addMovementNotes({ + month, + amount, + to, + from, + }); + }); } export async function setCategoryCarryover({ @@ -447,3 +479,52 @@ export async function setCategoryCarryover({ } }); } + +function addNewLine(notes?: string) { + return !notes ? '' : `${notes}${notes && '\n'}`; +} + +async function addMovementNotes({ + month, + amount, + to, + from, +}: { + month: string; + amount: number; + to: 'to-be-budgeted' | 'overbudgeted' | string; + from: 'to-be-budgeted' | string; +}) { + const displayAmount = integerToCurrency(amount); + + const monthBudgetNotesId = `budget-${month}`; + const existingMonthBudgetNotes = addNewLine( + db.firstSync(`SELECT n.note FROM notes n WHERE n.id = ?`, [ + monthBudgetNotesId, + ])?.note, + ); + + const displayDay = monthUtils.format(monthUtils.currentDate(), 'MMMM dd'); + const categories = await db.getCategories( + [from, to].filter(c => c !== 'to-be-budgeted' && c !== 'overbudgeted'), + ); + + const fromCategoryName = + from === 'to-be-budgeted' + ? 'To Budget' + : categories.find(c => c.id === from)?.name; + + const toCategoryName = + to === 'to-be-budgeted' + ? 'To Budget' + : to === 'overbudgeted' + ? 'Overbudgeted' + : categories.find(c => c.id === to)?.name; + + const note = `Reassigned ${displayAmount} from ${fromCategoryName} → ${toCategoryName} on ${displayDay}`; + + await db.update('notes', { + id: monthBudgetNotesId, + note: `${existingMonthBudgetNotes}- ${note}`, + }); +} diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 04f49ee36..412f15270 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -282,23 +282,36 @@ export function updateWithSchema(table, fields) { // Data-specific functions. Ideally this would be split up into // different files -export async function getCategories(): Promise<CategoryEntity[]> { - return await all(` - SELECT c.* FROM categories c WHERE c.tombstone = 0 - ORDER BY c.sort_order, c.id - `); -} - -export async function getCategoriesGrouped(): Promise< - Array<CategoryGroupEntity> -> { - const groups = await all(` - SELECT cg.* FROM category_groups cg WHERE cg.tombstone = 0 ORDER BY cg.is_income, cg.sort_order, cg.id - `); - const categories = await all(` - SELECT c.* FROM categories c WHERE c.tombstone = 0 - ORDER BY c.sort_order, c.id - `); +export async function getCategories( + ids?: Array<CategoryEntity['id']>, +): Promise<CategoryEntity[]> { + const whereIn = ids ? `c.id IN (${toSqlQueryParameters(ids)}) AND` : ''; + const query = `SELECT c.* FROM categories c WHERE ${whereIn} c.tombstone = 0 ORDER BY c.sort_order, c.id`; + return ids ? await all(query, [...ids]) : await all(query); +} + +export async function getCategoriesGrouped( + ids?: Array<CategoryGroupEntity['id']>, +): Promise<Array<CategoryGroupEntity>> { + const categoryGroupWhereIn = ids + ? `cg.id IN (${toSqlQueryParameters(ids)}) AND` + : ''; + const categoryGroupQuery = `SELECT cg.* FROM category_groups cg WHERE ${categoryGroupWhereIn} cg.tombstone = 0 + ORDER BY cg.is_income, cg.sort_order, cg.id`; + + const categoryWhereIn = ids + ? `c.cat_group IN (${toSqlQueryParameters(ids)}) AND` + : ''; + const categoryQuery = `SELECT c.* FROM categories c WHERE ${categoryWhereIn} c.tombstone = 0 + ORDER BY c.sort_order, c.id`; + + const groups = ids + ? await all(categoryGroupQuery, [...ids]) + : await all(categoryGroupQuery); + + const categories = ids + ? await all(categoryQuery, [...ids]) + : await all(categoryQuery); return groups.map(group => { return { @@ -553,7 +566,7 @@ export function getCommonPayees() { return all(` SELECT p.id as id, p.name as name, p.favorite as favorite, p.category as category, TRUE as common, NULL as transfer_acct, - count(*) as c, + count(*) as c, max(t.date) as latest FROM payees p LEFT JOIN v_transactions_internal_alive t on t.payee == p.id @@ -561,7 +574,7 @@ export function getCommonPayees() { AND p.tombstone = 0 AND t.date > ${twelveWeeksAgo} GROUP BY p.id - ORDER BY c DESC ,p.transfer_acct IS NULL DESC, p.name + ORDER BY c DESC ,p.transfer_acct IS NULL DESC, p.name COLLATE NOCASE LIMIT ${limit} `); @@ -681,3 +694,7 @@ export function updateTransaction(transaction) { export async function deleteTransaction(transaction) { return delete_('transactions', transaction.id); } + +function toSqlQueryParameters(params: unknown[]) { + return params.map(() => '?').join(','); +} diff --git a/packages/loot-core/src/server/notes/app.ts b/packages/loot-core/src/server/notes/app.ts index 466ac86e1..aac00a662 100644 --- a/packages/loot-core/src/server/notes/app.ts +++ b/packages/loot-core/src/server/notes/app.ts @@ -1,3 +1,4 @@ +import { NoteEntity } from '../../types/models'; import { createApp } from '../app'; import * as db from '../db'; @@ -5,6 +6,8 @@ import { NotesHandlers } from './types/handlers'; export const app = createApp<NotesHandlers>(); -app.method('notes-save', async ({ id, note }) => { +async function updateNotes({ id, note }: NoteEntity) { await db.update('notes', { id, note }); -}); +} + +app.method('notes-save', updateNotes); diff --git a/upcoming-release-notes/3411.md b/upcoming-release-notes/3411.md new file mode 100644 index 000000000..ca639b898 --- /dev/null +++ b/upcoming-release-notes/3411.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [joel-jeremy] +--- + +Undoable auto tranfer notes + auto notes for cover -- GitLab