diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png index 1aeaa14f98c..ce13427af87 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png index 76c562bd188..4b22f0e2f6e 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png index 39f7c042e38..85905bc0ee1 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png index 8320db53651..efc32336eed 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png index 591897b5a47..42cb054ab58 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png index 3d879590fd4..034509229c4 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/page-models/mobile-account-page.js b/packages/desktop-client/e2e/page-models/mobile-account-page.js index 9e58489fb74..0d831015c3b 100644 --- a/packages/desktop-client/e2e/page-models/mobile-account-page.js +++ b/packages/desktop-client/e2e/page-models/mobile-account-page.js @@ -5,7 +5,7 @@ export class MobileAccountPage { this.page = page; this.heading = page.getByRole('heading'); - this.balance = page.getByTestId('account-balance'); + this.balance = page.getByTestId('transactions-balance'); this.noTransactionsFoundError = page.getByText('No transactions'); this.searchBox = page.getByPlaceholder(/^Search/); this.transactionList = page.getByLabel('transaction list'); diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index 5fc229e140b..5a63cdd58b7 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -27,7 +27,7 @@ import { DevelopmentTopBar } from './DevelopmentTopBar'; import { FatalError } from './FatalError'; import { FinancesApp } from './FinancesApp'; import { ManagementApp } from './manager/ManagementApp'; -import { MobileWebMessage } from './MobileWebMessage'; +import { MobileWebMessage } from './mobile/MobileWebMessage'; import { UpdateNotification } from './UpdateNotification'; type AppInnerProps = { diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 2a1bc50a067..4718a93d465 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -30,6 +30,7 @@ import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext'; import { View } from './common/View'; import { GlobalKeys } from './GlobalKeys'; import { ManageRulesPage } from './ManageRulesPage'; +import { Category } from './mobile/budget/Category'; import { MobileNavTabs } from './mobile/MobileNavTabs'; import { TransactionEdit } from './mobile/transactions/TransactionEdit'; import { Modals } from './Modals'; @@ -210,7 +211,7 @@ function FinancesAppWithoutContext() { /> @@ -219,18 +220,10 @@ function FinancesAppWithoutContext() { /> - - - } - /> - - + } /> diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 95e0713602f..824a59460bf 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -14,7 +14,7 @@ import { useSyncServerStatus } from '../hooks/useSyncServerStatus'; import { ModalTitle } from './common/Modal'; import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal'; import { AccountMenuModal } from './modals/AccountMenuModal'; -import { BudgetMenuModal } from './modals/BudgetMenuModal'; +import { BudgetMonthMenuModal } from './modals/BudgetMonthMenuModal'; import { CategoryAutocompleteModal } from './modals/CategoryAutocompleteModal'; import { CategoryGroupMenuModal } from './modals/CategoryGroupMenuModal'; import { CategoryMenuModal } from './modals/CategoryMenuModal'; @@ -40,8 +40,10 @@ import { Notes } from './modals/Notes'; import { PayeeAutocompleteModal } from './modals/PayeeAutocompleteModal'; import { PlaidExternalMsg } from './modals/PlaidExternalMsg'; import { ReportBalanceMenuModal } from './modals/ReportBalanceMenuModal'; +import { ReportBudgetMenuModal } from './modals/ReportBudgetMenuModal'; import { ReportBudgetSummaryModal } from './modals/ReportBudgetSummaryModal'; import { RolloverBalanceMenuModal } from './modals/RolloverBalanceMenuModal'; +import { RolloverBudgetMenuModal } from './modals/RolloverBudgetMenuModal'; import { RolloverBudgetSummaryModal } from './modals/RolloverBudgetSummaryModal'; import { RolloverToBudgetMenuModal } from './modals/RolloverToBudgetMenuModal'; import { ScheduledTransactionMenuModal } from './modals/ScheduledTransactionMenuModal'; @@ -429,7 +431,6 @@ export function Modals() { key={name} modalProps={modalProps} categoryId={options.categoryId} - categoryGroup={options.categoryGroup} onSave={options.onSave} onEditNotes={options.onEditNotes} onDelete={options.onDelete} @@ -437,6 +438,40 @@ export function Modals() { /> ); + case 'rollover-budget-menu': + return ( + + + + ); + + case 'report-budget-menu': + return ( + + + + ); + case 'category-group-menu': return ( ); - case 'rollover-to-budget-menu': + case 'rollover-summary-to-budget-menu': return ( ); - case 'budget-menu': + case 'budget-month-menu': return ( - ({ + ...trans, + _inverse: accountId ? accountId !== trans.account : false, + })); transactions ??= []; - const schedules = useMemo( - () => - scheduleData - ? scheduleData.schedules.filter( - s => - !s.completed && - ['due', 'upcoming', 'missed'].includes( - scheduleData.statuses.get(s.id), - ), - ) - : [], - [scheduleData], - ); - - const prependTransactions = useMemo(() => { - return schedules.map(schedule => ({ - id: `preview/${schedule.id}`, - payee: schedule._payee, - account: schedule._account, - amount: schedule._amount, - date: schedule.next_date, - notes: scheduleData.statuses.get(schedule.id), - schedule: schedule.id, - _inverse: accountId ? accountId !== schedule._account : false, - })); - }, [schedules, accountId]); - let runningBalance = useMemo(() => { if (!showBalances) { return 0; @@ -172,7 +146,7 @@ function AllTransactions({ return balances; }, [filtered, prependBalances, balances]); - if (scheduleData == null) { + if (!prependTransactions) { return children(transactions, balances); } return children(allTransactions, allBalances); diff --git a/packages/desktop-client/src/components/budget/BudgetTable.jsx b/packages/desktop-client/src/components/budget/BudgetTable.jsx index cc61c3d8b5e..f4d45208dce 100644 --- a/packages/desktop-client/src/components/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/budget/BudgetTable.jsx @@ -1,7 +1,5 @@ import React, { useRef, useState } from 'react'; -import * as monthUtils from 'loot-core/src/shared/months'; - import { useCategories } from '../../hooks/useCategories'; import { useLocalPref } from '../../hooks/useLocalPref'; import { theme, styles } from '../../style'; @@ -40,8 +38,8 @@ export function BudgetTable(props) { ); const [editing, setEditing] = useState(null); - const onEditMonth = (id, monthIndex) => { - setEditing(id ? { id, cell: monthIndex } : null); + const onEditMonth = (id, month) => { + setEditing(id ? { id, cell: month } : null); }; const onEditName = id => { @@ -134,18 +132,6 @@ export function BudgetTable(props) { } }; - const resolveMonth = monthIndex => { - return monthUtils.addMonths(startMonth, monthIndex); - }; - - const _onShowActivity = (catId, monthIndex) => { - onShowActivity(catId, resolveMonth(monthIndex)); - }; - - const _onBudgetAction = (monthIndex, type, args) => { - onBudgetAction(resolveMonth(monthIndex), type, args); - }; - const onCollapse = collapsedIds => { setCollapsedPref(collapsedIds); }; @@ -244,8 +230,8 @@ export function BudgetTable(props) { onDeleteGroup={onDeleteGroup} onReorderCategory={_onReorderCategory} onReorderGroup={_onReorderGroup} - onBudgetAction={_onBudgetAction} - onShowActivity={_onShowActivity} + onBudgetAction={onBudgetAction} + onShowActivity={onShowActivity} /> diff --git a/packages/desktop-client/src/components/budget/ExpenseCategory.tsx b/packages/desktop-client/src/components/budget/ExpenseCategory.tsx index 5007b81c8d1..bb9da828c58 100644 --- a/packages/desktop-client/src/components/budget/ExpenseCategory.tsx +++ b/packages/desktop-client/src/components/budget/ExpenseCategory.tsx @@ -28,12 +28,12 @@ type ExpenseCategoryProps = { dragState: DragState; MonthComponent: ComponentProps['component']; onEditName?: ComponentProps['onEditName']; - onEditMonth?: (id: string, monthIndex: number) => void; + onEditMonth?: (id: string, month: string) => void; onSave?: ComponentProps['onSave']; onDelete?: ComponentProps['onDelete']; onDragChange: OnDragChangeCallback; - onBudgetAction: (idx: number, action: string, arg: unknown) => void; - onShowActivity: (id: string, idx: number) => void; + onBudgetAction: (month: number, action: string, arg: unknown) => void; + onShowActivity: (id: string, month: string) => void; onReorder: OnDropCallback; }; @@ -101,7 +101,7 @@ export function ExpenseCategory({ ['component']; onEditName: ComponentProps['onEditName']; - onEditMonth?: (id: string, monthIndex: number) => void; + onEditMonth?: (id: string, month: string) => void; onSave: ComponentProps['onSave']; onDelete: ComponentProps['onDelete']; onDragChange: OnDragChangeCallback; - onBudgetAction: (idx: number, action: string, arg: unknown) => void; + onBudgetAction: (month: string, action: string, arg: unknown) => void; onReorder: OnDropCallback; - onShowActivity: (id: string, idx: number) => void; + onShowActivity: (id: string, month: string) => void; }; export function IncomeCategory({ @@ -76,7 +76,7 @@ export function IncomeCategory({ /> ; - editingIndex?: string | number; + component?: ComponentType<{ month: string; editing: boolean }>; + editingMonth?: string; args?: object; style?: CSSProperties; }; export function RenderMonths({ component: Component, - editingIndex, + editingMonth, args, style, }: RenderMonthsProps) { const { months } = useContext(MonthsContext); return months.map((month, index) => { - const editing = editingIndex === index; + const editing = editingMonth === month; return ( - + ); diff --git a/packages/desktop-client/src/components/budget/report/BalanceTooltip.tsx b/packages/desktop-client/src/components/budget/report/BalanceTooltip.tsx index 66999627bf7..91aa1ade6d1 100644 --- a/packages/desktop-client/src/components/budget/report/BalanceTooltip.tsx +++ b/packages/desktop-client/src/components/budget/report/BalanceTooltip.tsx @@ -7,15 +7,15 @@ import { BalanceMenu } from './BalanceMenu'; type BalanceTooltipProps = { categoryId: string; tooltip: { close: () => void }; - monthIndex: number; - onBudgetAction: (idx: number, action: string, arg: unknown) => void; + month: string; + onBudgetAction: (month: string, action: string, arg: unknown) => void; onClose?: () => void; }; export function BalanceTooltip({ categoryId, tooltip, - monthIndex, + month, onBudgetAction, onClose, ...tooltipProps @@ -36,7 +36,7 @@ export function BalanceTooltip({ { - onBudgetAction?.(monthIndex, 'carryover', { + onBudgetAction?.(month, 'carryover', { category: categoryId, flag: carryover, }); diff --git a/packages/desktop-client/src/components/budget/report/BudgetMenu.tsx b/packages/desktop-client/src/components/budget/report/BudgetMenu.tsx new file mode 100644 index 00000000000..aeed3cddd88 --- /dev/null +++ b/packages/desktop-client/src/components/budget/report/BudgetMenu.tsx @@ -0,0 +1,75 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; +import { Menu } from '../../common/Menu'; + +type BudgetMenuProps = Omit< + ComponentPropsWithoutRef, + 'onMenuSelect' | 'items' +> & { + onCopyLastMonthAverage: () => void; + onSetMonthsAverage: (numberOfMonths: number) => void; + onApplyBudgetTemplate: () => void; +}; +export function BudgetMenu({ + onCopyLastMonthAverage, + onSetMonthsAverage, + onApplyBudgetTemplate, + ...props +}: BudgetMenuProps) { + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); + const onMenuSelect = (name: string) => { + switch (name) { + case 'copy-single-last': + onCopyLastMonthAverage?.(); + break; + case 'set-single-3-avg': + onSetMonthsAverage?.(3); + break; + case 'set-single-6-avg': + onSetMonthsAverage?.(6); + break; + case 'set-single-12-avg': + onSetMonthsAverage?.(12); + break; + case 'apply-single-category-template': + onApplyBudgetTemplate?.(); + break; + default: + throw new Error(`Unrecognized menu item: ${name}`); + } + }; + + return ( + + ); +} diff --git a/packages/desktop-client/src/components/budget/report/ReportComponents.tsx b/packages/desktop-client/src/components/budget/report/ReportComponents.tsx index 3181c8da7cb..5d09472c8ff 100644 --- a/packages/desktop-client/src/components/budget/report/ReportComponents.tsx +++ b/packages/desktop-client/src/components/budget/report/ReportComponents.tsx @@ -5,11 +5,9 @@ import { reportBudget } from 'loot-core/src/client/queries'; import { evalArithmetic } from 'loot-core/src/shared/arithmetic'; import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util'; -import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; import { SvgCheveronDown } from '../../../icons/v1'; import { styles, theme, type CSSProperties } from '../../../style'; import { Button } from '../../common/Button'; -import { Menu } from '../../common/Menu'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; import { CellValue } from '../../spreadsheet/CellValue'; @@ -20,6 +18,7 @@ import { BalanceWithCarryover } from '../BalanceWithCarryover'; import { makeAmountGrey } from '../util'; import { BalanceTooltip } from './BalanceTooltip'; +import { BudgetMenu } from './BudgetMenu'; const headerLabelStyle: CSSProperties = { flex: 1, @@ -142,15 +141,15 @@ export const GroupMonth = memo(function GroupMonth({ group }: GroupMonthProps) { }); type CategoryMonthProps = { - monthIndex: number; + month: string; category: { id: string; name: string; is_income: boolean }; editing: boolean; - onEdit: (id: string | null, idx?: number) => void; - onBudgetAction: (idx: number, action: string, arg: unknown) => void; - onShowActivity: (id: string, idx: number) => void; + onEdit: (id: string | null, month?: string) => void; + onBudgetAction: (month: string, action: string, arg: unknown) => void; + onShowActivity: (id: string, month: string) => void; }; export const CategoryMonth = memo(function CategoryMonth({ - monthIndex, + month, category, editing, onEdit, @@ -160,7 +159,6 @@ export const CategoryMonth = memo(function CategoryMonth({ const balanceTooltip = useTooltip(); const [menuOpen, setMenuOpen] = useState(false); const [hover, setHover] = useState(false); - const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); return ( setMenuOpen(false)} > - { - onBudgetAction(monthIndex, type, { category: category.id }); - setMenuOpen(false); + { + onBudgetAction?.(month, 'copy-single-last', { + category: category.id, + }); + }} + onSetMonthsAverage={numberOfMonths => { + if ( + numberOfMonths !== 3 && + numberOfMonths !== 6 && + numberOfMonths !== 12 + ) { + return; + } + + onBudgetAction?.( + month, + `set-single-${numberOfMonths}-avg`, + { + category: category.id, + }, + ); + }} + onApplyBudgetTemplate={() => { + onBudgetAction?.(month, 'apply-single-category-template', { + category: category.id, + }); }} - items={[ - { - name: 'copy-single-last', - text: 'Copy last month’s budget', - }, - { - name: 'set-single-3-avg', - text: 'Set to 3 month average', - }, - { - name: 'set-single-6-avg', - text: 'Set to 6 month average', - }, - { - name: 'set-single-12-avg', - text: 'Set to yearly average', - }, - isGoalTemplatesEnabled && { - name: 'apply-single-category-template', - text: 'Apply budget template', - }, - ]} /> )} @@ -258,7 +257,7 @@ export const CategoryMonth = memo(function CategoryMonth({ exposed={editing} focused={editing} width="flex" - onExpose={() => onEdit(category.id, monthIndex)} + onExpose={() => onEdit(category.id, month)} style={{ ...(editing && { zIndex: 100 }), ...styles.tnum }} textAlign="right" valueStyle={{ @@ -291,7 +290,7 @@ export const CategoryMonth = memo(function CategoryMonth({ }, }} onSave={amount => { - onBudgetAction(monthIndex, 'budget-amount', { + onBudgetAction(month, 'budget-amount', { category: category.id, amount, }); @@ -301,7 +300,7 @@ export const CategoryMonth = memo(function CategoryMonth({ onShowActivity(category.id, monthIndex)} + onClick={() => onShowActivity(category.id, month)} > )} diff --git a/packages/desktop-client/src/components/budget/report/ReportContext.tsx b/packages/desktop-client/src/components/budget/report/ReportContext.tsx index c1c90e93d72..d93d8cbe66b 100644 --- a/packages/desktop-client/src/components/budget/report/ReportContext.tsx +++ b/packages/desktop-client/src/components/budget/report/ReportContext.tsx @@ -7,7 +7,7 @@ const Context = createContext(null); type ReportProviderProps = { summaryCollapsed: boolean; - onBudgetAction: (idx: number, action: string, arg: unknown) => void; + onBudgetAction: (month: string, action: string, arg: unknown) => void; onToggleSummaryCollapse: () => void; children: ReactNode; }; diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceTooltip.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceTooltip.tsx index 66b8f86ba30..1432556c096 100644 --- a/packages/desktop-client/src/components/budget/rollover/BalanceTooltip.tsx +++ b/packages/desktop-client/src/components/budget/rollover/BalanceTooltip.tsx @@ -12,15 +12,15 @@ import { TransferTooltip } from './TransferTooltip'; type BalanceTooltipProps = { categoryId: string; tooltip: { close: () => void }; - monthIndex: number; - onBudgetAction: (idx: number, action: string, arg?: unknown) => void; + month: string; + onBudgetAction: (month: string, action: string, arg?: unknown) => void; onClose?: () => void; }; export function BalanceTooltip({ categoryId, tooltip, - monthIndex, + month, onBudgetAction, onClose, ...tooltipProps @@ -46,7 +46,7 @@ export function BalanceTooltip({ { - onBudgetAction(monthIndex, 'carryover', { + onBudgetAction(month, 'carryover', { category: categoryId, flag: carryover, }); @@ -64,7 +64,7 @@ export function BalanceTooltip({ showToBeBudgeted={true} onClose={_onClose} onSubmit={(amount, toCategoryId) => { - onBudgetAction(monthIndex, 'transfer-category', { + onBudgetAction(month, 'transfer-category', { amount, from: categoryId, to: toCategoryId, @@ -77,7 +77,7 @@ export function BalanceTooltip({ { - onBudgetAction(monthIndex, 'cover', { + onBudgetAction(month, 'cover', { to: categoryId, from: fromCategoryId, }); diff --git a/packages/desktop-client/src/components/budget/rollover/BudgetMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BudgetMenu.tsx new file mode 100644 index 00000000000..aeed3cddd88 --- /dev/null +++ b/packages/desktop-client/src/components/budget/rollover/BudgetMenu.tsx @@ -0,0 +1,75 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; +import { Menu } from '../../common/Menu'; + +type BudgetMenuProps = Omit< + ComponentPropsWithoutRef, + 'onMenuSelect' | 'items' +> & { + onCopyLastMonthAverage: () => void; + onSetMonthsAverage: (numberOfMonths: number) => void; + onApplyBudgetTemplate: () => void; +}; +export function BudgetMenu({ + onCopyLastMonthAverage, + onSetMonthsAverage, + onApplyBudgetTemplate, + ...props +}: BudgetMenuProps) { + const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); + const onMenuSelect = (name: string) => { + switch (name) { + case 'copy-single-last': + onCopyLastMonthAverage?.(); + break; + case 'set-single-3-avg': + onSetMonthsAverage?.(3); + break; + case 'set-single-6-avg': + onSetMonthsAverage?.(6); + break; + case 'set-single-12-avg': + onSetMonthsAverage?.(12); + break; + case 'apply-single-category-template': + onApplyBudgetTemplate?.(); + break; + default: + throw new Error(`Unrecognized menu item: ${name}`); + } + }; + + return ( + + ); +} diff --git a/packages/desktop-client/src/components/budget/rollover/RolloverComponents.tsx b/packages/desktop-client/src/components/budget/rollover/RolloverComponents.tsx index a8e326f9601..b1ba0ff719e 100644 --- a/packages/desktop-client/src/components/budget/rollover/RolloverComponents.tsx +++ b/packages/desktop-client/src/components/budget/rollover/RolloverComponents.tsx @@ -1,14 +1,13 @@ -import React, { memo, useState } from 'react'; +import type React from 'react'; +import { memo, useState } from 'react'; import { rolloverBudget } from 'loot-core/src/client/queries'; import { evalArithmetic } from 'loot-core/src/shared/arithmetic'; import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util'; -import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; import { SvgCheveronDown } from '../../../icons/v1'; import { styles, theme, type CSSProperties } from '../../../style'; import { Button } from '../../common/Button'; -import { Menu } from '../../common/Menu'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; import { CellValue } from '../../spreadsheet/CellValue'; @@ -19,6 +18,7 @@ import { BalanceWithCarryover } from '../BalanceWithCarryover'; import { makeAmountGrey } from '../util'; import { BalanceTooltip } from './BalanceTooltip'; +import { BudgetMenu } from './BudgetMenu'; const headerLabelStyle: CSSProperties = { flex: 1, @@ -137,15 +137,15 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({ }); type ExpenseCategoryMonthProps = { - monthIndex: number; + month: string; category: { id: string; name: string; is_income: boolean }; editing: boolean; - onEdit: (id: string | null, idx?: number) => void; - onBudgetAction: (idx: number, action: string, arg?: unknown) => void; - onShowActivity: (id: string, idx: number) => void; + onEdit: (id: string | null, month?: string) => void; + onBudgetAction: (month: string, action: string, arg?: unknown) => void; + onShowActivity: (id: string, month: string) => void; }; export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ - monthIndex, + month, category, editing, onEdit, @@ -155,7 +155,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ const balanceTooltip = useTooltip(); const [menuOpen, setMenuOpen] = useState(false); const [hover, setHover] = useState(false); - const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); return ( setMenuOpen(false)} > - { - onBudgetAction(monthIndex, type, { category: category.id }); - setMenuOpen(false); + { + onBudgetAction?.(month, 'copy-single-last', { + category: category.id, + }); + }} + onSetMonthsAverage={numberOfMonths => { + if ( + numberOfMonths !== 3 && + numberOfMonths !== 6 && + numberOfMonths !== 12 + ) { + return; + } + + onBudgetAction?.( + month, + `set-single-${numberOfMonths}-avg`, + { + category: category.id, + }, + ); + }} + onApplyBudgetTemplate={() => { + onBudgetAction?.(month, 'apply-single-category-template', { + category: category.id, + }); }} - items={[ - { - name: 'copy-single-last', - text: 'Copy last month’s budget', - }, - { - name: 'set-single-3-avg', - text: 'Set to 3 month average', - }, - { - name: 'set-single-6-avg', - text: 'Set to 6 month average', - }, - { - name: 'set-single-12-avg', - text: 'Set to yearly average', - }, - ...(isGoalTemplatesEnabled - ? [ - { - name: 'apply-single-category-template', - text: 'Apply budget template', - }, - ] - : []), - ]} /> )} @@ -257,7 +253,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ exposed={editing} focused={editing} width="flex" - onExpose={() => onEdit(category.id, monthIndex)} + onExpose={() => onEdit(category.id, month)} style={{ ...(editing && { zIndex: 100 }), ...styles.tnum }} textAlign="right" valueStyle={{ @@ -290,7 +286,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ }, }} onSave={amount => { - onBudgetAction(monthIndex, 'budget-amount', { + onBudgetAction(month, 'budget-amount', { category: category.id, amount, }); @@ -300,7 +296,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ onShowActivity(category.id, monthIndex)} + onClick={() => onShowActivity(category.id, month)} > )} @@ -369,13 +365,13 @@ export function IncomeGroupMonth() { type IncomeCategoryMonthProps = { category: { id: string; name: string }; isLast: boolean; - monthIndex: number; - onShowActivity: (id: string, idx: number) => void; + month: string; + onShowActivity: (id: string, month: string) => void; }; export function IncomeCategoryMonth({ category, isLast, - monthIndex, + month, onShowActivity, }: IncomeCategoryMonthProps) { return ( @@ -389,7 +385,7 @@ export function IncomeCategoryMonth({ ...(isLast && { borderBottomWidth: 0 }), }} > - onShowActivity(category.id, monthIndex)}> + onShowActivity(category.id, month)}> void; + onBudgetAction: (month: string, action: string, arg?: unknown) => void; onToggleSummaryCollapse: () => void; currentMonth: string; }; 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 75051811aa0..5630ad92bfe 100644 --- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx +++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx @@ -13,7 +13,7 @@ import { ToBudgetMenu } from './ToBudgetMenu'; type ToBudgetProps = { month: string; - onBudgetAction: (idx: string, action: string, arg?: unknown) => void; + onBudgetAction: (month: string, action: string, arg?: unknown) => void; prevMonthName: string; showTotalsTooltipOnHover?: boolean; style?: CSSProperties; diff --git a/packages/desktop-client/src/components/common/Link.tsx b/packages/desktop-client/src/components/common/Link.tsx index d624bdaf682..1fd6a027742 100644 --- a/packages/desktop-client/src/components/common/Link.tsx +++ b/packages/desktop-client/src/components/common/Link.tsx @@ -102,7 +102,9 @@ const ButtonLink = ({ to, style, activeStyle, ...props }: ButtonLinkProps) => { {...props} onClick={e => { props.onClick?.(e); - navigate(path); + if (!e.defaultPrevented) { + navigate(path); + } }} /> ); diff --git a/packages/desktop-client/src/components/MobileBackButton.tsx b/packages/desktop-client/src/components/mobile/MobileBackButton.tsx similarity index 76% rename from packages/desktop-client/src/components/MobileBackButton.tsx rename to packages/desktop-client/src/components/mobile/MobileBackButton.tsx index d13b504dc72..f563dcab834 100644 --- a/packages/desktop-client/src/components/MobileBackButton.tsx +++ b/packages/desktop-client/src/components/mobile/MobileBackButton.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import { useNavigate } from '../hooks/useNavigate'; -import { SvgCheveronLeft } from '../icons/v1'; -import { type CSSProperties, styles, theme } from '../style'; - -import { Button } from './common/Button'; -import { Text } from './common/Text'; +import { useNavigate } from '../../hooks/useNavigate'; +import { SvgCheveronLeft } from '../../icons/v1'; +import { type CSSProperties, styles, theme } from '../../style'; +import { Button } from '../common/Button'; +import { Text } from '../common/Text'; type MobileBackButtonProps = { style?: CSSProperties; @@ -16,6 +15,7 @@ export function MobileBackButton({ style }: MobileBackButtonProps) { return ( @@ -1398,8 +1369,7 @@ function MonthSelector({ month, monthBounds, onPrevMonth, onNextMonth }) { fontWeight: 500, }} > - {/* eslint-disable-next-line rulesdir/typography */} - {monthUtils.format(month, "MMMM ''yy")} + {monthUtils.format(month, 'MMMM ‘yy')} + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx similarity index 67% rename from packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx rename to packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx index 482759d5f76..17b0d0d3281 100644 --- a/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/FocusableAmountInput.tsx @@ -1,4 +1,12 @@ -import { memo, useEffect, useRef, useState } from 'react'; +import React, { + type Ref, + type ComponentPropsWithRef, + type HTMLProps, + memo, + useEffect, + useRef, + useState, +} from 'react'; import { toRelaxedNumber, @@ -8,23 +16,41 @@ import { import { useLocalPref } from '../../../hooks/useLocalPref'; import { useMergedRefs } from '../../../hooks/useMergedRefs'; -import { theme } from '../../../style'; +import { type CSSProperties, theme } from '../../../style'; import { Button } from '../../common/Button'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; +type AmountInputProps = { + value: number; + focused?: boolean; + style?: CSSProperties; + textStyle?: CSSProperties; + inputRef?: Ref; + onFocus?: HTMLProps['onFocus']; + onBlur?: HTMLProps['onBlur']; + onEnter?: HTMLProps['onKeyUp']; + onChangeValue?: (value: string) => void; + onUpdate?: (value: string) => void; + onUpdateAmount?: (value: number) => void; +}; + const AmountInput = memo(function AmountInput({ focused, style, textStyle, ...props -}) { +}: AmountInputProps) { const [editing, setEditing] = useState(false); const [text, setText] = useState(''); const [value, setValue] = useState(0); - const inputRef = useRef(); + const inputRef = useRef(); const [hideFraction = false] = useLocalPref('hideFraction'); - const mergedInputRef = useMergedRefs(props.inputRef, inputRef); + + const mergedInputRef = useMergedRefs( + props.inputRef, + inputRef, + ); const initialValue = Math.abs(props.value); @@ -44,11 +70,17 @@ const AmountInput = memo(function AmountInput({ return toRelaxedNumber(text.replace(/[,.]/, getNumberFormat().separator)); }; - const onKeyPress = e => { + const onKeyUp: HTMLProps['onKeyUp'] = e => { if (e.key === 'Backspace' && text === '') { setEditing(true); + } else if (e.key === 'Enter') { + props.onEnter?.(e); + if (!e.defaultPrevented) { + onUpdate(e.currentTarget.value); + } } }; + const applyText = () => { const parsed = parseText(); const newValue = editing ? parsed : value; @@ -60,17 +92,24 @@ const AmountInput = memo(function AmountInput({ return newValue; }; - const onFocus = e => { + const onFocus: HTMLProps['onFocus'] = e => { props.onFocus?.(e); }; - const onBlur = e => { - const value = applyText(); + const onUpdate = (value: string) => { props.onUpdate?.(value); + const amount = applyText(); + props.onUpdateAmount?.(amount); + }; + + const onBlur: HTMLProps['onBlur'] = e => { props.onBlur?.(e); + if (!e.defaultPrevented) { + onUpdate(e.target.value); + } }; - const onChangeText = text => { + const onChangeText = (text: string) => { if (text.slice(-1) === '.') { text = text.slice(0, -1); } @@ -83,7 +122,7 @@ const AmountInput = memo(function AmountInput({ setEditing(true); setText(text); - props.onChange?.(text); + props.onChangeValue?.(text); }; const input = ( @@ -96,7 +135,7 @@ const AmountInput = memo(function AmountInput({ onChange={e => onChangeText(e.target.value)} onFocus={onFocus} onBlur={onBlur} - onKeyUp={onKeyPress} + onKeyUp={onKeyUp} data-testid="amount-input" style={{ flex: 1, textAlign: 'center', position: 'absolute' }} /> @@ -111,14 +150,17 @@ const AmountInput = memo(function AmountInput({ borderRadius: 4, padding: 5, backgroundColor: theme.tableBackground, + maxWidth: 'calc(100% - 40px)', ...style, }} > {input} {editing ? amountToCurrency(text) : amountToCurrency(value)} @@ -126,11 +168,22 @@ const AmountInput = memo(function AmountInput({ ); }); +type FocusableAmountInputProps = Omit & { + sign?: '+' | '-'; + zeroSign?: '+' | '-'; + focused?: boolean; + disabled?: boolean; + focusedStyle?: CSSProperties; + buttonProps?: ComponentPropsWithRef; + onFocus?: () => void; +}; + export const FocusableAmountInput = memo(function FocusableAmountInput({ value, - sign, // + or - - zeroSign, // + or - + sign, + zeroSign, focused, + disabled, textStyle, style, focusedStyle, @@ -138,9 +191,18 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({ onFocus, onBlur, ...props -}) { +}: FocusableAmountInputProps) { const [isNegative, setIsNegative] = useState(true); + const maybeApplyNegative = (amount: number, negative: boolean) => { + const absValue = Math.abs(amount); + return negative ? -absValue : absValue; + }; + + const onUpdateAmount = (amount: number, negative: boolean) => { + props.onUpdateAmount?.(maybeApplyNegative(amount, negative)); + }; + useEffect(() => { if (sign) { setIsNegative(sign === '-'); @@ -150,17 +212,12 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({ }, [sign, value, zeroSign]); const toggleIsNegative = () => { - setIsNegative(!isNegative); - props.onUpdate?.(maybeApplyNegative(value, !isNegative)); - }; - - const maybeApplyNegative = (val, negative) => { - const absValue = Math.abs(val); - return negative ? -absValue : absValue; - }; + if (disabled) { + return; + } - const onUpdate = val => { - props.onUpdate?.(maybeApplyNegative(val, isNegative)); + onUpdateAmount(value, !isNegative); + setIsNegative(!isNegative); }; return ( @@ -170,8 +227,8 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({ value={value} onFocus={onFocus} onBlur={onBlur} - onUpdate={onUpdate} - focused={focused} + onUpdateAmount={amount => onUpdateAmount(amount, isNegative)} + focused={focused && !disabled} style={{ width: 80, justifyContent: 'center', @@ -216,7 +273,6 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({ borderBottomWidth: 1, borderColor: '#e0e0e0', justifyContent: 'center', - transform: [{ translateY: 0.5 }], ...style, }} > diff --git a/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx index 028c1787e3e..402a950d8d3 100644 --- a/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx @@ -15,7 +15,7 @@ export function ListBox(props) { const { loadMore } = props; const { hasScrolledToBottom } = useScroll(); - const scrolledToBottom = hasScrolledToBottom(); + const scrolledToBottom = hasScrolledToBottom(5); const prevScrolledToBottom = usePrevious(scrolledToBottom); if (!prevScrolledToBottom && scrolledToBottom) { diff --git a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx index 055239cc7d6..855c7f44a63 100644 --- a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx @@ -1,9 +1,13 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo } from 'react'; import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { isPreviewId } from 'loot-core/src/shared/transactions'; -import { integerToCurrency, groupById } from 'loot-core/src/shared/util'; +import { integerToCurrency } from 'loot-core/src/shared/util'; +import { useAccount } from '../../../hooks/useAccount'; +import { useCategories } from '../../../hooks/useCategories'; +import { usePayee } from '../../../hooks/usePayee'; +import { SvgSplit } from '../../../icons/v0'; import { SvgArrowsSynchronize, SvgCheckCircle1, @@ -41,28 +45,29 @@ ListItem.displayName = 'ListItem'; export const Transaction = memo(function Transaction({ transaction, - account, - accounts, - categories, - payees, added, onSelect, style, }) { - const accountsById = useMemo(() => groupById(accounts), [accounts]); - const payeesById = useMemo(() => groupById(payees), [payees]); + const { list: categories } = useCategories(); const { id, payee: payeeId, amount: originalAmount, category: categoryId, + account: accountId, cleared, is_parent: isParent, + is_child: isChild, notes, schedule, } = transaction; + const payee = usePayee(payeeId); + const account = useAccount(accountId); + const transferAcct = useAccount(payee?.transfer_acct); + const isPreview = isPreviewId(id); let amount = originalAmount; if (isPreview) { @@ -71,10 +76,6 @@ export const Transaction = memo(function Transaction({ const categoryName = lookupName(categories, categoryId); - const payee = payeesById && payeeId && payeesById[payeeId]; - const transferAcct = - payee && payee.transfer_acct && accountsById[payee.transfer_acct]; - const prettyDescription = getDescriptionPretty( transaction, payee, @@ -173,6 +174,15 @@ export const Transaction = memo(function Transaction({ }} /> )} + {(isParent || isChild) && ( + + )} @@ -947,21 +946,28 @@ function isTemporary(transaction) { return transaction.id.indexOf('temp') === 0; } -function makeTemporaryTransactions(currentAccountId, lastDate) { +function makeTemporaryTransactions(accountId, categoryId, lastDate) { return [ { id: 'temp', date: lastDate || monthUtils.currentDay(), - account: currentAccountId, + account: accountId, + category: categoryId, amount: 0, cleared: false, }, ]; } -function TransactionEditUnconnected(props) { - const { categories, accounts, payees, lastTransaction, dateFormat } = props; - const { id: accountId, transactionId } = useParams(); +function TransactionEditUnconnected({ + categories, + accounts, + payees, + lastTransaction, + dateFormat, +}) { + const { transactionId } = useParams(); + const { state: locationState } = useLocation(); const navigate = useNavigate(); const dispatch = useDispatch(); const [transactions, setTransactions] = useState([]); @@ -991,7 +997,7 @@ function TransactionEditUnconnected(props) { setTransactions(fetchedTransactions); setFetchedTransactions(fetchedTransactions); } - if (transactionId) { + if (transactionId !== 'new') { fetchTransaction(); } else { adding.current = true; @@ -1002,12 +1008,13 @@ function TransactionEditUnconnected(props) { if (adding.current) { setTransactions( makeTemporaryTransactions( - accountId || lastTransaction?.account || null, + locationState?.accountId || lastTransaction?.account || null, + locationState?.categoryId || lastTransaction?.category || null, lastTransaction?.date, ), ); } - }, [accountId, lastTransaction]); + }, [locationState?.accountId, locationState?.categoryId, lastTransaction]); if ( categories.length === 0 || diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx index e76fe396d02..332d45c1002 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx @@ -12,10 +12,6 @@ import { ListBox } from './ListBox'; import { Transaction } from './Transaction'; export function TransactionList({ - account, - accounts, - categories, - payees, transactions, isNew, onSelect, @@ -45,9 +41,7 @@ export function TransactionList({ }); } - if (!transaction.is_child) { - sections[sections.length - 1].data.push(transaction); - } + sections[sections.length - 1].data.push(transaction); }); return sections; }, [transactions]); @@ -98,12 +92,8 @@ export function TransactionList({ > ); diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx new file mode 100644 index 00000000000..1eddb932d2f --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { SvgSearchAlternate } from '../../../icons/v2'; +import { styles, theme } from '../../../style'; +import { InputWithContent } from '../../common/InputWithContent'; +import { Label } from '../../common/Label'; +import { View } from '../../common/View'; +import { CellValue } from '../../spreadsheet/CellValue'; +import { useSheetValue } from '../../spreadsheet/useSheetValue'; +import { PullToRefresh } from '../PullToRefresh'; + +import { TransactionList } from './TransactionList'; + +function TransactionSearchInput({ placeholder, onSearch }) { + const [text, setText] = useState(''); + + return ( + + + } + value={text} + onChangeValue={text => { + setText(text); + onSearch(text); + }} + placeholder={placeholder} + style={{ + backgroundColor: theme.tableBackground, + border: `1px solid ${theme.formInputBorder}`, + flex: 1, + height: styles.mobileMinHeight, + }} + /> + + ); +} + +export function TransactionListWithBalances({ + transactions, + balance, + balanceCleared, + balanceUncleared, + searchPlaceholder = 'Search...', + onSearch, + onLoadMore, + onSelectTransaction, + onRefresh, +}) { + const newTransactions = useSelector(state => state.queries.newTransactions); + + const isNewTransaction = id => { + return newTransactions.includes(id); + }; + + const unclearedAmount = useSheetValue(balanceUncleared); + + return ( + <> + + + + + + + + + + + + + + + + ); +} diff --git a/packages/desktop-client/src/components/modals/AccountMenuModal.tsx b/packages/desktop-client/src/components/modals/AccountMenuModal.tsx index 33c456f8739..f798c50327a 100644 --- a/packages/desktop-client/src/components/modals/AccountMenuModal.tsx +++ b/packages/desktop-client/src/components/modals/AccountMenuModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { type ComponentProps, useState } from 'react'; import { useLiveQuery } from 'loot-core/src/client/query-hooks'; import { q } from 'loot-core/src/shared/query'; @@ -146,7 +146,8 @@ export function AccountMenuModal({ flexWrap: 'wrap', justifyContent: 'space-between', alignContent: 'space-between', - margin: '10px 0', + paddingTop: 10, + paddingBottom: 10, }} >