diff --git a/packages/desktop-client/src/components/budget/index.tsx b/packages/desktop-client/src/components/budget/index.tsx index 2d339b755ad300a649364630721bc8935a19841d..cad97746ba6d82c210c963d068153b00549495b9 100644 --- a/packages/desktop-client/src/components/budget/index.tsx +++ b/packages/desktop-client/src/components/budget/index.tsx @@ -39,7 +39,7 @@ import { DynamicBudgetTable } from './DynamicBudgetTable'; import * as report from './report/ReportComponents'; import { ReportProvider } from './report/ReportContext'; import * as rollover from './rollover/RolloverComponents'; -import { RolloverContext } from './rollover/RolloverContext'; +import { RolloverProvider } from './rollover/RolloverContext'; import { prewarmAllMonths, prewarmMonth, switchBudgetType } from './util'; type ReportComponents = { @@ -378,7 +378,7 @@ function BudgetInner(props: BudgetInnerProps) { ); } else { table = ( - <RolloverContext + <RolloverProvider summaryCollapsed={summaryCollapsed} onBudgetAction={onBudgetAction} onToggleSummaryCollapse={onToggleCollapse} @@ -400,7 +400,7 @@ function BudgetInner(props: BudgetInnerProps) { onReorderCategory={onReorderCategory} onReorderGroup={onReorderGroup} /> - </RolloverContext> + </RolloverProvider> ); } diff --git a/packages/desktop-client/src/components/budget/report/BalanceMenu.tsx b/packages/desktop-client/src/components/budget/report/BalanceMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0878a98310bdd0f24f2d36312e8818778ac8142d --- /dev/null +++ b/packages/desktop-client/src/components/budget/report/BalanceMenu.tsx @@ -0,0 +1,44 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { reportBudget } from 'loot-core/src/client/queries'; + +import { Menu } from '../../common/Menu'; +import { useSheetValue } from '../../spreadsheet/useSheetValue'; + +type BalanceMenuProps = Omit< + ComponentPropsWithoutRef<typeof Menu>, + 'onMenuSelect' | 'items' +> & { + categoryId: string; + onCarryover: (carryover: boolean) => void; +}; + +export function BalanceMenu({ + categoryId, + onCarryover, + ...props +}: BalanceMenuProps) { + const carryover = useSheetValue(reportBudget.catCarryover(categoryId)); + return ( + <Menu + {...props} + onMenuSelect={name => { + switch (name) { + case 'carryover': + onCarryover?.(!carryover); + break; + default: + throw new Error(`Unsupported item: ${name}`); + } + }} + items={[ + { + name: 'carryover', + text: carryover + ? 'Remove overspending rollover' + : 'Rollover overspending', + }, + ]} + /> + ); +} diff --git a/packages/desktop-client/src/components/budget/report/BalanceTooltip.tsx b/packages/desktop-client/src/components/budget/report/BalanceTooltip.tsx index bf806d12b9067813f55c8cbf5b5309c41b21bc5f..66999627bf7800736b0683af2adeabb17325721d 100644 --- a/packages/desktop-client/src/components/budget/report/BalanceTooltip.tsx +++ b/packages/desktop-client/src/components/budget/report/BalanceTooltip.tsx @@ -1,11 +1,9 @@ import React from 'react'; -import { reportBudget } from 'loot-core/src/client/queries'; - -import { Menu } from '../../common/Menu'; -import { useSheetValue } from '../../spreadsheet/useSheetValue'; import { Tooltip } from '../../tooltips'; +import { BalanceMenu } from './BalanceMenu'; + type BalanceTooltipProps = { categoryId: string; tooltip: { close: () => void }; @@ -22,8 +20,6 @@ export function BalanceTooltip({ onClose, ...tooltipProps }: BalanceTooltipProps) { - const carryover = useSheetValue(reportBudget.catCarryover(categoryId)); - const _onClose = () => { tooltip.close(); onClose?.(); @@ -37,22 +33,15 @@ export function BalanceTooltip({ onClose={_onClose} {...tooltipProps} > - <Menu - onMenuSelect={() => { - onBudgetAction(monthIndex, 'carryover', { + <BalanceMenu + categoryId={categoryId} + onCarryover={carryover => { + onBudgetAction?.(monthIndex, 'carryover', { category: categoryId, - flag: !carryover, + flag: carryover, }); _onClose(); }} - items={[ - { - name: 'carryover', - text: carryover - ? 'Remove overspending rollover' - : 'Rollover overspending', - }, - ]} /> </Tooltip> ); diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..803fcbe58b056f100bfabb632f0c8d503562ae48 --- /dev/null +++ b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx @@ -0,0 +1,67 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { rolloverBudget } from 'loot-core/src/client/queries'; + +import { Menu } from '../../common/Menu'; +import { useSheetValue } from '../../spreadsheet/useSheetValue'; + +type BalanceMenuProps = Omit< + ComponentPropsWithoutRef<typeof Menu>, + 'onMenuSelect' | 'items' +> & { + categoryId: string; + onTransfer: () => void; + onCarryover: (carryOver: boolean) => void; + onCover: () => void; +}; + +export function BalanceMenu({ + categoryId, + onTransfer, + onCarryover, + onCover, + ...props +}: BalanceMenuProps) { + const carryover = useSheetValue(rolloverBudget.catCarryover(categoryId)); + const balance = useSheetValue(rolloverBudget.catBalance(categoryId)); + return ( + <Menu + {...props} + onMenuSelect={name => { + switch (name) { + case 'transfer': + onTransfer?.(); + break; + case 'carryover': + onCarryover?.(!carryover); + break; + case 'cover': + onCover?.(); + break; + default: + throw new Error(`Unsupported item: ${name}`); + } + }} + items={[ + { + name: 'transfer', + text: 'Transfer to another category', + }, + { + name: 'carryover', + text: carryover + ? 'Remove overspending rollover' + : 'Rollover overspending', + }, + ...(balance < 0 + ? [ + { + name: 'cover', + text: 'Cover overspending', + }, + ] + : []), + ]} + /> + ); +} diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceTooltip.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceTooltip.tsx index 5ad6251205aa7ff62e99458dad60e1edff171be9..66b8f86ba30538d0de14b7dd7e1c597b4bb744ed 100644 --- a/packages/desktop-client/src/components/budget/rollover/BalanceTooltip.tsx +++ b/packages/desktop-client/src/components/budget/rollover/BalanceTooltip.tsx @@ -2,10 +2,10 @@ import React, { useState } from 'react'; import { rolloverBudget } from 'loot-core/src/client/queries'; -import { Menu } from '../../common/Menu'; import { useSheetValue } from '../../spreadsheet/useSheetValue'; import { Tooltip } from '../../tooltips'; +import { BalanceMenu } from './BalanceMenu'; import { CoverTooltip } from './CoverTooltip'; import { TransferTooltip } from './TransferTooltip'; @@ -16,6 +16,7 @@ type BalanceTooltipProps = { onBudgetAction: (idx: number, action: string, arg?: unknown) => void; onClose?: () => void; }; + export function BalanceTooltip({ categoryId, tooltip, @@ -24,8 +25,7 @@ export function BalanceTooltip({ onClose, ...tooltipProps }: BalanceTooltipProps) { - const carryover = useSheetValue(rolloverBudget.catCarryover(categoryId)); - const balance = useSheetValue(rolloverBudget.catBalance(categoryId)); + const catBalance = useSheetValue(rolloverBudget.catBalance(categoryId)); const [menu, setMenu] = useState('menu'); const _onClose = () => { @@ -43,52 +43,31 @@ export function BalanceTooltip({ onClose={_onClose} {...tooltipProps} > - <Menu - onMenuSelect={type => { - if (type === 'carryover') { - onBudgetAction(monthIndex, 'carryover', { - category: categoryId, - flag: !carryover, - }); - _onClose(); - } else { - setMenu(type); - } + <BalanceMenu + categoryId={categoryId} + onCarryover={carryover => { + onBudgetAction(monthIndex, 'carryover', { + category: categoryId, + flag: carryover, + }); + _onClose(); }} - items={[ - { - name: 'transfer', - text: 'Transfer to another category', - }, - { - name: 'carryover', - text: carryover - ? 'Remove overspending rollover' - : 'Rollover overspending', - }, - ...(balance < 0 - ? [ - { - name: 'cover', - text: 'Cover overspending', - }, - ] - : []), - ]} + onTransfer={() => setMenu('transfer')} + onCover={() => setMenu('cover')} /> </Tooltip> )} {menu === 'transfer' && ( <TransferTooltip - initialAmountName={rolloverBudget.catBalance(categoryId)} + initialAmount={catBalance} showToBeBudgeted={true} onClose={_onClose} - onSubmit={(amount, toCategory) => { + onSubmit={(amount, toCategoryId) => { onBudgetAction(monthIndex, 'transfer-category', { amount, from: categoryId, - to: toCategory, + to: toCategoryId, }); }} /> @@ -97,10 +76,10 @@ export function BalanceTooltip({ {menu === 'cover' && ( <CoverTooltip onClose={_onClose} - onSubmit={fromCategory => { + onSubmit={fromCategoryId => { onBudgetAction(monthIndex, 'cover', { to: categoryId, - from: fromCategory, + from: fromCategoryId, }); }} /> diff --git a/packages/desktop-client/src/components/budget/rollover/CoverTooltip.tsx b/packages/desktop-client/src/components/budget/rollover/CoverTooltip.tsx index 590fe23e773d2f22bd07522a8985a7f19d36aa96..60458e71e8ecfb033bab8f6b0bf8d60b0ca4b798 100644 --- a/packages/desktop-client/src/components/budget/rollover/CoverTooltip.tsx +++ b/packages/desktop-client/src/components/budget/rollover/CoverTooltip.tsx @@ -10,7 +10,7 @@ import { addToBeBudgetedGroup } from '../util'; type CoverTooltipProps = { tooltipProps?: ComponentProps<typeof Tooltip>; - onSubmit: (category: unknown) => void; + onSubmit: (categoryId: string) => void; onClose: () => void; }; export function CoverTooltip({ @@ -18,18 +18,10 @@ export function CoverTooltip({ onSubmit, onClose, }: CoverTooltipProps) { - const { grouped } = useCategories(); - const categoryGroups = addToBeBudgetedGroup( - grouped.filter(g => !g.is_income), - ); - const [category, setCategory] = useState<string | null>(null); - - function submit() { - if (category) { - onSubmit(category); - onClose(); - } - } + const _onSubmit = (categoryId: string) => { + onSubmit?.(categoryId); + onClose?.(); + }; return ( <Tooltip @@ -39,16 +31,38 @@ export function CoverTooltip({ {...tooltipProps} onClose={onClose} > + <Cover onSubmit={_onSubmit} /> + </Tooltip> + ); +} + +type CoverProps = { + onSubmit: (categoryId: string) => void; +}; + +function Cover({ onSubmit }: CoverProps) { + const { grouped: originalCategoryGroups } = useCategories(); + const categoryGroups = addToBeBudgetedGroup( + originalCategoryGroups.filter(g => !g.is_income), + ); + const [categoryId, setCategoryId] = useState<string | null>(null); + + function submit() { + if (categoryId) { + onSubmit(categoryId); + } + } + return ( + <> <View style={{ marginBottom: 5 }}>Cover from category:</View> <InitialFocus> {node => ( <CategoryAutocomplete categoryGroups={categoryGroups} - value={null} + value={categoryGroups.find(g => g.id === categoryId)} openOnFocus={true} - onUpdate={() => {}} - onSelect={(id: string | undefined) => setCategory(id || null)} + onSelect={(id: string | undefined) => setCategoryId(id || null)} inputProps={{ inputRef: node, onKeyDown: e => { @@ -56,6 +70,7 @@ export function CoverTooltip({ submit(); } }, + placeholder: '(none)', }} showHiddenCategories={false} /> @@ -79,6 +94,6 @@ export function CoverTooltip({ Transfer </Button> </View> - </Tooltip> + </> ); } diff --git a/packages/desktop-client/src/components/budget/rollover/RolloverContext.tsx b/packages/desktop-client/src/components/budget/rollover/RolloverContext.tsx index 583444a4716b294025c061a23a4d5dabfff84c5d..ea05cd8737444ee10c9e1b6a6f1e1c6b1c7813da 100644 --- a/packages/desktop-client/src/components/budget/rollover/RolloverContext.tsx +++ b/packages/desktop-client/src/components/budget/rollover/RolloverContext.tsx @@ -22,15 +22,15 @@ const Context = createContext<RolloverContextDefinition>({ currentMonth: 'unknown', }); -type RolloverContextProps = Omit<RolloverContextDefinition, 'currentMonth'> & { +type RolloverProviderProps = Omit<RolloverContextDefinition, 'currentMonth'> & { children: ReactNode; }; -export function RolloverContext({ +export function RolloverProvider({ summaryCollapsed, onBudgetAction, onToggleSummaryCollapse, children, -}: RolloverContextProps) { +}: RolloverProviderProps) { const currentMonth = monthUtils.currentMonth(); return ( diff --git a/packages/desktop-client/src/components/budget/rollover/TransferTooltip.tsx b/packages/desktop-client/src/components/budget/rollover/TransferTooltip.tsx index d321eea3aa3fbb1bc9f24c9295225b4ea101cb0d..f23e53ca5f1eaf82bb0cee77d9396f80dcaffaf2 100644 --- a/packages/desktop-client/src/components/budget/rollover/TransferTooltip.tsx +++ b/packages/desktop-client/src/components/budget/rollover/TransferTooltip.tsx @@ -1,11 +1,6 @@ -import React, { - useState, - useContext, - useEffect, - type ComponentPropsWithoutRef, -} from 'react'; +import type React from 'react'; +import { useState, type ComponentPropsWithoutRef } from 'react'; -import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; import { evalArithmetic } from 'loot-core/src/shared/arithmetic'; import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util'; @@ -15,79 +10,78 @@ import { Button } from '../../common/Button'; import { InitialFocus } from '../../common/InitialFocus'; import { Input } from '../../common/Input'; import { View } from '../../common/View'; -import { NamespaceContext } from '../../spreadsheet/NamespaceContext'; import { Tooltip } from '../../tooltips'; import { addToBeBudgetedGroup } from '../util'; type TransferTooltipProps = ComponentPropsWithoutRef<typeof Tooltip> & { initialAmount?: number; - initialAmountName?: string; showToBeBudgeted?: boolean; - onSubmit: (amount: number, category: string) => void; + onSubmit: (amount: number, categoryId: string) => void; }; + export function TransferTooltip({ initialAmount = 0, - initialAmountName, showToBeBudgeted, onSubmit, onClose, position = 'bottom-right', ...props }: TransferTooltipProps) { - const spreadsheet = useSpreadsheet(); - const sheetName = useContext(NamespaceContext); - let { grouped: categoryGroups } = useCategories(); + const _onSubmit = (amount: number, categoryId: string) => { + onSubmit?.(amount, categoryId); + onClose?.(); + }; + + return ( + <Tooltip + position={position} + width={200} + style={{ padding: 10 }} + onClose={onClose} + {...props} + > + <Transfer amount={initialAmount} showToBeBudgeted onSubmit={_onSubmit} /> + </Tooltip> + ); +} + +type TransferProps = { + amount: number; + showToBeBudgeted: boolean; + onSubmit: (amount: number, categoryId: string) => void; +}; - categoryGroups = categoryGroups.filter(g => !g.is_income); +function Transfer({ + amount: initialAmount, + showToBeBudgeted, + onSubmit, +}: TransferProps) { + const { grouped: originalCategoryGroups } = useCategories(); + let categoryGroups = originalCategoryGroups.filter(g => !g.is_income); if (showToBeBudgeted) { categoryGroups = addToBeBudgetedGroup(categoryGroups); } + const _initialAmount = integerToCurrency(Math.max(initialAmount, 0)); const [amount, setAmount] = useState<string | null>(null); - const [category, setCategory] = useState<string | null>(null); + const [categoryId, setCategoryId] = useState<string | null>(null); - useEffect(() => { - (async () => { - if (initialAmountName) { - const node = await spreadsheet.get(sheetName, initialAmountName); - setAmount(integerToCurrency(Math.max(node.value as number, 0))); - } else { - setAmount(integerToCurrency(Math.max(initialAmount, 0))); - } - })(); - }, []); - - function submit(newAmount: string) { - const parsedAmount = evalArithmetic(newAmount); - if (parsedAmount && category) { - onSubmit(amountToInteger(parsedAmount), category); - onClose(); + const _onSubmit = (newAmount: string | null, categoryId: string | null) => { + const parsedAmount = evalArithmetic(newAmount || ''); + if (parsedAmount && categoryId) { + onSubmit?.(amountToInteger(parsedAmount), categoryId); } - } - - if (amount === null) { - // Don't render anything until we have the amount to show. This - // ensures that the amount field is focused and fully selected - // when it's initially rendered (instead of being updated - // afterwards and losing selection) - return null; - } + }; return ( - <Tooltip - position={position} - width={200} - style={{ padding: 10 }} - onClose={onClose} - {...props} - > + <> <View style={{ marginBottom: 5 }}>Transfer this amount:</View> <View> <InitialFocus> <Input - value={amount} - onChange={e => setAmount(e.target['value'])} - onEnter={() => submit(amount)} + defaultValue={_initialAmount} + onUpdate={value => setAmount(value)} + onEnter={() => _onSubmit(amount, categoryId)} /> </InitialFocus> </View> @@ -95,11 +89,13 @@ export function TransferTooltip({ <CategoryAutocomplete categoryGroups={categoryGroups} - value={null} + value={categoryGroups.find(g => g.id === categoryId)} openOnFocus={true} - onUpdate={() => {}} - onSelect={(id: string | undefined) => setCategory(id || null)} - inputProps={{ onEnter: () => submit(amount), placeholder: '(none)' }} + onSelect={(id: string | undefined) => setCategoryId(id || null)} + inputProps={{ + onEnter: () => _onSubmit(amount, categoryId), + placeholder: '(none)', + }} showHiddenCategories={true} /> @@ -116,11 +112,11 @@ export function TransferTooltip({ paddingTop: 3, paddingBottom: 3, }} - onClick={() => submit(amount)} + onClick={() => _onSubmit(amount, categoryId)} > Transfer </Button> </View> - </Tooltip> + </> ); } 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 02b1d1da6a4d4cae1a964de490a0ba11d9485161..75051811aa0ec9729935bfa66519f0ecbc5ec953 100644 --- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx +++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudget.tsx @@ -1,23 +1,15 @@ import React, { useState, type ComponentPropsWithoutRef } from 'react'; -import { css } from 'glamor'; - import { rolloverBudget } from 'loot-core/src/client/queries'; -import { theme, styles, type CSSProperties } from '../../../../style'; -import { Block } from '../../../common/Block'; -import { HoverTarget } from '../../../common/HoverTarget'; -import { Menu } from '../../../common/Menu'; -import { View } from '../../../common/View'; -import { PrivacyFilter } from '../../../PrivacyFilter'; -import { useFormat } from '../../../spreadsheet/useFormat'; -import { useSheetName } from '../../../spreadsheet/useSheetName'; +import { type CSSProperties } from '../../../../style'; import { useSheetValue } from '../../../spreadsheet/useSheetValue'; import { Tooltip } from '../../../tooltips'; import { HoldTooltip } from '../HoldTooltip'; import { TransferTooltip } from '../TransferTooltip'; -import { TotalsList } from './TotalsList'; +import { ToBudgetAmount } from './ToBudgetAmount'; +import { ToBudgetMenu } from './ToBudgetMenu'; type ToBudgetProps = { month: string; @@ -44,116 +36,62 @@ export function ToBudget({ transferTooltipProps, }: ToBudgetProps) { const [menuOpen, setMenuOpen] = useState<string | null>(null); - const sheetName = useSheetName(rolloverBudget.toBudget); const sheetValue = useSheetValue({ name: rolloverBudget.toBudget, value: 0, }); - const format = useFormat(); const availableValue = parseInt(sheetValue); - const num = isNaN(availableValue) ? 0 : availableValue; - const isNegative = num < 0; return ( - <View style={{ alignItems: 'center', ...style }}> - <Block>{isNegative ? 'Overbudgeted:' : 'To Budget:'}</Block> - <View> - <HoverTarget - disabled={!showTotalsTooltipOnHover || !!menuOpen} - renderContent={() => ( - <Tooltip position="bottom-center" {...totalsTooltipProps}> - <TotalsList - prevMonthName={prevMonthName} - style={{ - padding: 7, - }} - /> - </Tooltip> - )} + <> + <ToBudgetAmount + onClick={() => setMenuOpen('actions')} + prevMonthName={prevMonthName} + showTotalsTooltipOnHover={showTotalsTooltipOnHover} + totalsTooltipProps={totalsTooltipProps} + style={style} + amountStyle={amountStyle} + /> + {menuOpen === 'actions' && ( + <Tooltip + position="bottom-center" + width={200} + style={{ padding: 0 }} + onClose={() => setMenuOpen(null)} + {...menuTooltipProps} > - <PrivacyFilter blurIntensity={7}> - <Block - onClick={() => setMenuOpen('actions')} - data-cellname={sheetName} - className={`${css([ - styles.veryLargeText, - { - fontWeight: 400, - userSelect: 'none', - cursor: 'pointer', - color: isNegative ? theme.errorText : theme.pageTextPositive, - marginBottom: -1, - borderBottom: '1px solid transparent', - ':hover': { - borderColor: isNegative - ? theme.errorBorder - : theme.pageTextPositive, - }, - }, - amountStyle, - ])}`} - > - {format(num, 'financial')} - </Block> - </PrivacyFilter> - </HoverTarget> - {menuOpen === 'actions' && ( - <Tooltip - position="bottom-center" - width={200} - style={{ padding: 0 }} - onClose={() => setMenuOpen(null)} - {...menuTooltipProps} - > - <Menu - onMenuSelect={type => { - if (type === 'reset-buffer') { - onBudgetAction(month, 'reset-hold'); - setMenuOpen(null); - } else { - setMenuOpen(type); - } - }} - items={[ - { - name: 'transfer', - text: 'Move to a category', - }, - { - name: 'buffer', - text: 'Hold for next month', - }, - { - name: 'reset-buffer', - text: 'Reset next month’s buffer', - }, - ]} - /> - </Tooltip> - )} - {menuOpen === 'buffer' && ( - <HoldTooltip - onClose={() => setMenuOpen(null)} - onSubmit={amount => { - onBudgetAction(month, 'hold', { amount }); - }} - {...holdTooltipProps} - /> - )} - {menuOpen === 'transfer' && ( - <TransferTooltip - initialAmount={availableValue} - onClose={() => setMenuOpen(null)} - onSubmit={(amount, category) => { - onBudgetAction(month, 'transfer-available', { - amount, - category, - }); + <ToBudgetMenu + onTransfer={() => setMenuOpen('transfer')} + onHoldBuffer={() => setMenuOpen('buffer')} + onResetHoldBuffer={() => { + onBudgetAction(month, 'reset-hold'); + setMenuOpen(null); }} - {...transferTooltipProps} /> - )} - </View> - </View> + </Tooltip> + )} + {menuOpen === 'buffer' && ( + <HoldTooltip + onClose={() => setMenuOpen(null)} + onSubmit={amount => { + onBudgetAction(month, 'hold', { amount }); + }} + {...holdTooltipProps} + /> + )} + {menuOpen === 'transfer' && ( + <TransferTooltip + initialAmount={availableValue} + onClose={() => setMenuOpen(null)} + onSubmit={(amount, category) => { + onBudgetAction(month, 'transfer-available', { + amount, + category, + }); + }} + {...transferTooltipProps} + /> + )} + </> ); } diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetAmount.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetAmount.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4764903c74b7f230f4211ee94bc4177800e6a980 --- /dev/null +++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetAmount.tsx @@ -0,0 +1,92 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { css } from 'glamor'; + +import { rolloverBudget } from 'loot-core/src/client/queries'; + +import { theme, styles, type CSSProperties } from '../../../../style'; +import { Block } from '../../../common/Block'; +import { HoverTarget } from '../../../common/HoverTarget'; +import { View } from '../../../common/View'; +import { PrivacyFilter } from '../../../PrivacyFilter'; +import { useFormat } from '../../../spreadsheet/useFormat'; +import { useSheetName } from '../../../spreadsheet/useSheetName'; +import { useSheetValue } from '../../../spreadsheet/useSheetValue'; +import { Tooltip } from '../../../tooltips'; + +import { TotalsList } from './TotalsList'; + +type ToBudgetAmountProps = { + prevMonthName: string; + showTotalsTooltipOnHover?: boolean; + totalsTooltipProps?: ComponentPropsWithoutRef<typeof Tooltip>; + style?: CSSProperties; + amountStyle?: CSSProperties; + onClick: () => void; +}; + +export function ToBudgetAmount({ + prevMonthName, + showTotalsTooltipOnHover, + totalsTooltipProps, + style, + amountStyle, + onClick, +}: ToBudgetAmountProps) { + const sheetName = useSheetName(rolloverBudget.toBudget); + const sheetValue = useSheetValue({ + name: rolloverBudget.toBudget, + value: 0, + }); + const format = useFormat(); + const availableValue = parseInt(sheetValue); + const num = isNaN(availableValue) ? 0 : availableValue; + const isNegative = num < 0; + + return ( + <View style={{ alignItems: 'center', ...style }}> + <Block>{isNegative ? 'Overbudgeted:' : 'To Budget:'}</Block> + <View> + <HoverTarget + disabled={!showTotalsTooltipOnHover} + renderContent={() => ( + <Tooltip position="bottom-center" {...totalsTooltipProps}> + <TotalsList + prevMonthName={prevMonthName} + style={{ + padding: 7, + }} + /> + </Tooltip> + )} + > + <PrivacyFilter blurIntensity={7}> + <Block + onClick={onClick} + data-cellname={sheetName} + className={`${css([ + styles.veryLargeText, + { + fontWeight: 400, + userSelect: 'none', + cursor: 'pointer', + color: isNegative ? theme.errorText : theme.pageTextPositive, + marginBottom: -1, + borderBottom: '1px solid transparent', + ':hover': { + borderColor: isNegative + ? theme.errorBorder + : theme.pageTextPositive, + }, + }, + amountStyle, + ])}`} + > + {format(num, 'financial')} + </Block> + </PrivacyFilter> + </HoverTarget> + </View> + </View> + ); +} diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..183e7d28753c9ee11a208839b19bf924a10795b2 --- /dev/null +++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx @@ -0,0 +1,53 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { Menu } from '../../../common/Menu'; + +type ToBudgetMenuProps = Omit< + ComponentPropsWithoutRef<typeof Menu>, + 'onMenuSelect' | 'items' +> & { + onTransfer: () => void; + onHoldBuffer: () => void; + onResetHoldBuffer: () => void; +}; +export function ToBudgetMenu({ + onTransfer, + onHoldBuffer, + onResetHoldBuffer, + ...props +}: ToBudgetMenuProps) { + return ( + <Menu + {...props} + onMenuSelect={name => { + switch (name) { + case 'transfer': + onTransfer?.(); + break; + case 'buffer': + onHoldBuffer?.(); + break; + case 'reset-buffer': + onResetHoldBuffer?.(); + break; + default: + throw new Error(`Unsupported item: ${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', + }, + ]} + /> + ); +} diff --git a/packages/desktop-client/src/components/common/Menu.tsx b/packages/desktop-client/src/components/common/Menu.tsx index 4d5ed8691dc9cc9bbdfc9ed473b903bd7c8edc31..a948ca5ec4e086d83c25e1ed2b06f4f67cb6b99d 100644 --- a/packages/desktop-client/src/components/common/Menu.tsx +++ b/packages/desktop-client/src/components/common/Menu.tsx @@ -1,11 +1,11 @@ import { - type FunctionComponent, type ReactElement, type ReactNode, - createElement, useEffect, useRef, useState, + type ComponentType, + type SVGProps, } from 'react'; import { type CSSProperties, theme } from '../../style'; @@ -34,16 +34,10 @@ type MenuItem = { type?: string | symbol; name: string; disabled?: boolean; - // eslint-disable-next-line @typescript-eslint/ban-types - icon?: FunctionComponent<{ - width: number; - height: number; - style: CSSProperties; - }>; + icon?: ComponentType<SVGProps<SVGSVGElement>>; iconSize?: number; text: string; key?: string; - style?: CSSProperties; toggle?: boolean; tooltip?: string; }; @@ -54,6 +48,7 @@ type MenuProps<T extends MenuItem = MenuItem> = { items: Array<T | typeof Menu.line>; onMenuSelect?: (itemName: T['name']) => void; style?: CSSProperties; + getItemStyle?: (item: T) => CSSProperties; }; export function Menu<T extends MenuItem>({ @@ -62,6 +57,7 @@ export function Menu<T extends MenuItem>({ items: allItems, onMenuSelect, style, + getItemStyle, }: MenuProps<T>) { const elRef = useRef<HTMLDivElement>(null); const items = allItems.filter(x => x); @@ -149,6 +145,7 @@ export function Menu<T extends MenuItem>({ } const lastItem = items[idx - 1]; + const Icon = item.icon; return ( <View @@ -172,31 +169,22 @@ export function Menu<T extends MenuItem>({ backgroundColor: theme.menuItemBackgroundHover, color: theme.menuItemTextHover, }), - ...item.style, + ...getItemStyle?.(item), }} - onMouseEnter={() => setHoveredIndex(idx)} - onMouseLeave={() => setHoveredIndex(null)} - onClick={() => - !item.disabled && - onMenuSelect && - item.toggle === undefined && - onMenuSelect(item.name) - } + onPointerEnter={() => setHoveredIndex(idx)} + onPointerLeave={() => setHoveredIndex(null)} + onClick={() => !item.disabled && onMenuSelect?.(item.name)} > {/* Force it to line up evenly */} {item.toggle === undefined ? ( <> - <Text style={{ lineHeight: 0 }}> - {item.icon && - createElement(item.icon, { - width: item.iconSize || 10, - height: item.iconSize || 10, - style: { - marginRight: 7, - width: item.iconSize || 10, - }, - })} - </Text> + {Icon && ( + <Icon + width={item.iconSize || 10} + height={item.iconSize || 10} + style={{ marginRight: 7, width: item.iconSize || 10 }} + /> + )} <Text title={item.tooltip}>{item.text}</Text> <View style={{ flex: 1 }} /> </> @@ -210,12 +198,9 @@ export function Menu<T extends MenuItem>({ id={item.name} checked={item.toggle} onColor={theme.pageTextPositive} - style={{ marginLeft: 5, ...item.style }} + style={{ marginLeft: 5 }} onToggle={() => - !item.disabled && - onMenuSelect && - item.toggle !== undefined && - onMenuSelect(item.name) + !item.disabled && item.toggle && onMenuSelect?.(item.name) } /> </> diff --git a/packages/desktop-client/src/components/mobile/MobileForms.jsx b/packages/desktop-client/src/components/mobile/MobileForms.jsx deleted file mode 100644 index e3f46e0b1d424593ff5f33b7127757b3db179e3f..0000000000000000000000000000000000000000 --- a/packages/desktop-client/src/components/mobile/MobileForms.jsx +++ /dev/null @@ -1,150 +0,0 @@ -import { forwardRef } from 'react'; - -import { css } from 'glamor'; - -import { theme, styles } from '../../style'; -import { Button } from '../common/Button'; -import { Input } from '../common/Input'; -import { Text } from '../common/Text'; -import { View } from '../common/View'; - -const FIELD_HEIGHT = 40; - -export function FieldLabel({ title, flush, style }) { - return ( - <Text - style={{ - marginBottom: 5, - marginTop: flush ? 0 : 25, - fontSize: 13, - color: theme.tableRowHeaderText, - padding: `0 ${styles.mobileEditingPadding}px`, - textTransform: 'uppercase', - userSelect: 'none', - ...style, - }} - > - {title} - </Text> - ); -} - -const valueStyle = { - borderWidth: 1, - borderColor: theme.formInputBorder, - marginLeft: 8, - marginRight: 8, - height: FIELD_HEIGHT, -}; - -export const InputField = forwardRef(function InputField( - { disabled, style, onUpdate, ...props }, - ref, -) { - return ( - <Input - inputRef={ref} - autoCorrect="false" - autoCapitalize="none" - disabled={disabled} - onUpdate={onUpdate} - style={{ - ...valueStyle, - ...style, - color: disabled ? theme.tableTextInactive : theme.tableText, - backgroundColor: disabled - ? theme.formInputTextReadOnlySelection - : theme.tableBackground, - }} - {...props} - /> - ); -}); - -export function TapField({ - value, - children, - disabled, - rightContent, - style, - textStyle, - onClick, - ...props -}) { - return ( - <Button - as={View} - onClick={!disabled ? onClick : undefined} - style={{ - flexDirection: 'row', - alignItems: 'center', - ...style, - ...valueStyle, - backgroundColor: theme.tableBackground, - ...(disabled && { - backgroundColor: theme.formInputTextReadOnlySelection, - }), - }} - bounce={false} - activeStyle={{ - opacity: 0.5, - boxShadow: 'none', - }} - hoveredStyle={{ - boxShadow: 'none', - }} - // activeOpacity={0.05} - {...props} - > - {children ? ( - children - ) : ( - <Text style={{ flex: 1, userSelect: 'none', ...textStyle }}> - {value} - </Text> - )} - {!disabled && rightContent} - </Button> - ); -} - -export function BooleanField({ checked, onUpdate, style, disabled = false }) { - return ( - <input - disabled={disabled ? true : undefined} - type="checkbox" - checked={checked} - onChange={e => onUpdate(e.target.checked)} - className={`${css([ - { - marginInline: styles.mobileEditingPadding, - flexShrink: 0, - appearance: 'none', - outline: 0, - border: '1px solid ' + theme.formInputBorder, - borderRadius: 4, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - color: theme.checkboxText, - backgroundColor: theme.tableBackground, - ':checked': { - border: '1px solid ' + theme.checkboxBorderSelected, - backgroundColor: theme.checkboxBackgroundSelected, - '::after': { - display: 'block', - background: - theme.checkboxBackgroundSelected + - // eslint-disable-next-line rulesdir/typography - ' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="white" d="M0 11l2-2 5 5L18 3l2 2L7 18z"/></svg>\') 15px 15px', - width: 15, - height: 15, - content: ' ', - }, - }, - }, - style, - ])}`} - /> - ); -} diff --git a/packages/desktop-client/src/components/mobile/MobileForms.tsx b/packages/desktop-client/src/components/mobile/MobileForms.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6141192bdab16a9418a6307e619a85f3af070c36 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/MobileForms.tsx @@ -0,0 +1,186 @@ +import React, { + type ComponentPropsWithoutRef, + forwardRef, + type ReactNode, +} from 'react'; + +import { css } from 'glamor'; + +import { theme, styles, type CSSProperties } from '../../style'; +import { Button } from '../common/Button'; +import { Input } from '../common/Input'; +import { Text } from '../common/Text'; +import { View } from '../common/View'; + +type FieldLabelProps = { + title: string; + flush?: boolean; + style?: CSSProperties; +}; + +export function FieldLabel({ title, flush, style }: FieldLabelProps) { + return ( + <Text + style={{ + marginBottom: 5, + marginTop: flush ? 0 : 25, + fontSize: 13, + color: theme.tableRowHeaderText, + padding: `0 ${styles.mobileEditingPadding}px`, + textTransform: 'uppercase', + userSelect: 'none', + ...style, + }} + > + {title} + </Text> + ); +} + +const valueStyle = { + borderWidth: 1, + borderColor: theme.formInputBorder, + marginLeft: 8, + marginRight: 8, + height: styles.mobileMinHeight, +}; + +type InputFieldProps = ComponentPropsWithoutRef<typeof Input>; + +export const InputField = forwardRef<HTMLInputElement, InputFieldProps>( + ({ disabled, style, onUpdate, ...props }, ref) => { + return ( + <Input + inputRef={ref} + autoCorrect="false" + autoCapitalize="none" + disabled={disabled} + onUpdate={onUpdate} + style={{ + ...valueStyle, + ...style, + color: disabled ? theme.tableTextInactive : theme.tableText, + backgroundColor: disabled + ? theme.formInputTextReadOnlySelection + : theme.tableBackground, + }} + {...props} + /> + ); + }, +); + +InputField.displayName = 'InputField'; + +type TapFieldProps = ComponentPropsWithoutRef<typeof Button> & { + rightContent?: ReactNode; +}; + +export const TapField = forwardRef<HTMLButtonElement, TapFieldProps>( + ( + { + value, + children, + disabled, + rightContent, + style, + textStyle, + onClick, + ...props + }, + ref, + ) => { + return ( + <Button + // @ts-expect-error fix this later + as={View} + ref={ref} + onClick={!disabled ? onClick : undefined} + style={{ + flexDirection: 'row', + alignItems: 'center', + ...style, + ...valueStyle, + backgroundColor: theme.tableBackground, + ...(disabled && { + backgroundColor: theme.formInputTextReadOnlySelection, + }), + }} + bounce={false} + activeStyle={{ + opacity: 0.5, + boxShadow: 'none', + }} + hoveredStyle={{ + boxShadow: 'none', + }} + // activeOpacity={0.05} + {...props} + > + {children ? ( + children + ) : ( + <Text style={{ flex: 1, userSelect: 'none', ...textStyle }}> + {value} + </Text> + )} + {!disabled && rightContent} + </Button> + ); + }, +); + +TapField.displayName = 'TapField'; + +type BooleanFieldProps = { + checked: boolean; + disabled?: boolean; + onUpdate?: (checked: boolean) => void; + style?: CSSProperties; +}; + +export function BooleanField({ + checked, + onUpdate, + style, + disabled = false, +}: BooleanFieldProps) { + return ( + <input + disabled={disabled ? true : undefined} + type="checkbox" + checked={checked} + onChange={e => onUpdate?.(e.target.checked)} + className={`${css([ + { + marginInline: styles.mobileEditingPadding, + flexShrink: 0, + appearance: 'none', + outline: 0, + border: '1px solid ' + theme.formInputBorder, + borderRadius: 4, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.checkboxText, + backgroundColor: theme.tableBackground, + ':checked': { + border: '1px solid ' + theme.checkboxBorderSelected, + backgroundColor: theme.checkboxBackgroundSelected, + '::after': { + display: 'block', + background: + theme.checkboxBackgroundSelected + + // eslint-disable-next-line rulesdir/typography + ' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="white" d="M0 11l2-2 5 5L18 3l2 2L7 18z"/></svg>\') 15px 15px', + width: 15, + height: 15, + content: ' ', + }, + }, + }, + style, + ])}`} + /> + ); +} diff --git a/upcoming-release-notes/2511.md b/upcoming-release-notes/2511.md new file mode 100644 index 0000000000000000000000000000000000000000..cfcca376d5e3f96846e720fd1e344765757319af --- /dev/null +++ b/upcoming-release-notes/2511.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Split menu components to separate files for reusability.