mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
* Add Notes to Monthly Budget Cell Changed Modal menus layout to follow month menu on mobile * Fixed rebase errors * Update VRT screenshots Auto-generated by VRT workflow PR: #6620 * Addressed youngcw's comments (notes id format, notesButton defaultColor and modal layout) * Update VRT screenshots Auto-generated by VRT workflow PR: #6620 * Updated mobile budget menu modal page model --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: youngcw <calebyoung94@gmail.com>
513 lines
15 KiB
TypeScript
513 lines
15 KiB
TypeScript
// @ts-strict-ignore
|
|
import React, { memo, useRef, useState } from 'react';
|
|
import type { ComponentProps, CSSProperties } from 'react';
|
|
import { Trans } from 'react-i18next';
|
|
|
|
import { Button } from '@actual-app/components/button';
|
|
import { SvgCheveronDown } from '@actual-app/components/icons/v1';
|
|
import {
|
|
SvgArrowsSynchronize,
|
|
SvgCalendar3,
|
|
} from '@actual-app/components/icons/v2';
|
|
import { Popover } from '@actual-app/components/popover';
|
|
import { styles } from '@actual-app/components/styles';
|
|
import { Text } from '@actual-app/components/text';
|
|
import { theme } from '@actual-app/components/theme';
|
|
import { View } from '@actual-app/components/view';
|
|
import { css } from '@emotion/css';
|
|
import { t } from 'i18next';
|
|
|
|
import * as monthUtils from 'loot-core/shared/months';
|
|
|
|
import type { CategoryGroupMonthProps, CategoryMonthProps } from '..';
|
|
|
|
import { BalanceMenu } from './BalanceMenu';
|
|
import { BudgetMenu } from './BudgetMenu';
|
|
|
|
import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover';
|
|
import { makeAmountGrey } from '@desktop-client/components/budget/util';
|
|
import { NotesButton } from '@desktop-client/components/NotesButton';
|
|
import {
|
|
CellValue,
|
|
CellValueText,
|
|
} from '@desktop-client/components/spreadsheet/CellValue';
|
|
import { Field, SheetCell } from '@desktop-client/components/table';
|
|
import type { SheetCellProps } from '@desktop-client/components/table';
|
|
import { useCategoryScheduleGoalTemplateIndicator } from '@desktop-client/hooks/useCategoryScheduleGoalTemplateIndicator';
|
|
import { useFormat } from '@desktop-client/hooks/useFormat';
|
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
|
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
|
import { useUndo } from '@desktop-client/hooks/useUndo';
|
|
import type { Binding, SheetFields } from '@desktop-client/spreadsheet';
|
|
import { trackingBudget } from '@desktop-client/spreadsheet/bindings';
|
|
|
|
export const useTrackingSheetValue = <
|
|
FieldName extends SheetFields<'tracking-budget'>,
|
|
>(
|
|
binding: Binding<'tracking-budget', FieldName>,
|
|
) => {
|
|
return useSheetValue(binding);
|
|
};
|
|
|
|
const TrackingCellValue = <FieldName extends SheetFields<'tracking-budget'>>(
|
|
props: ComponentProps<typeof CellValue<'tracking-budget', FieldName>>,
|
|
) => {
|
|
return <CellValue {...props} />;
|
|
};
|
|
|
|
const TrackingSheetCell = <FieldName extends SheetFields<'tracking-budget'>>(
|
|
props: SheetCellProps<'tracking-budget', FieldName>,
|
|
) => {
|
|
return <SheetCell {...props} />;
|
|
};
|
|
|
|
const headerLabelStyle: CSSProperties = {
|
|
flex: 1,
|
|
padding: '0 5px',
|
|
textAlign: 'right',
|
|
};
|
|
|
|
const cellStyle: CSSProperties = {
|
|
color: theme.tableHeaderText,
|
|
fontWeight: 600,
|
|
};
|
|
|
|
export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
marginRight: styles.monthRightPadding,
|
|
paddingTop: 10,
|
|
paddingBottom: 10,
|
|
}}
|
|
>
|
|
<View style={headerLabelStyle}>
|
|
<Text style={{ color: theme.tableHeaderText }}>
|
|
<Trans>Budgeted</Trans>
|
|
</Text>
|
|
<TrackingCellValue
|
|
binding={trackingBudget.totalBudgetedExpense}
|
|
type="financial"
|
|
>
|
|
{props => <CellValueText {...props} style={cellStyle} />}
|
|
</TrackingCellValue>
|
|
</View>
|
|
<View style={headerLabelStyle}>
|
|
<Text style={{ color: theme.tableHeaderText }}>
|
|
<Trans>Spent</Trans>
|
|
</Text>
|
|
<TrackingCellValue binding={trackingBudget.totalSpent} type="financial">
|
|
{props => <CellValueText {...props} style={cellStyle} />}
|
|
</TrackingCellValue>
|
|
</View>
|
|
<View style={headerLabelStyle}>
|
|
<Text style={{ color: theme.tableHeaderText }}>
|
|
<Trans>Balance</Trans>
|
|
</Text>
|
|
<TrackingCellValue
|
|
binding={trackingBudget.totalLeftover}
|
|
type="financial"
|
|
>
|
|
{props => <CellValueText {...props} style={cellStyle} />}
|
|
</TrackingCellValue>
|
|
</View>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
export function IncomeHeaderMonth() {
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
marginRight: styles.monthRightPadding,
|
|
paddingBottom: 5,
|
|
}}
|
|
>
|
|
<View style={headerLabelStyle}>
|
|
<Text style={{ color: theme.tableHeaderText }}>
|
|
<Trans>Budgeted</Trans>
|
|
</Text>
|
|
</View>
|
|
<View style={headerLabelStyle}>
|
|
<Text style={{ color: theme.tableHeaderText }}>
|
|
<Trans>Received</Trans>
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export const GroupMonth = memo(function GroupMonth({
|
|
month,
|
|
group,
|
|
}: CategoryGroupMonthProps) {
|
|
const { id } = group;
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
backgroundColor: monthUtils.isCurrentMonth(month)
|
|
? theme.budgetHeaderCurrentMonth
|
|
: theme.budgetHeaderOtherMonth,
|
|
}}
|
|
>
|
|
<TrackingSheetCell
|
|
name="budgeted"
|
|
width="flex"
|
|
textAlign="right"
|
|
style={{ fontWeight: 600, ...styles.tnum }}
|
|
valueProps={{
|
|
binding: trackingBudget.groupBudgeted(id),
|
|
type: 'financial',
|
|
}}
|
|
/>
|
|
<TrackingSheetCell
|
|
name="spent"
|
|
width="flex"
|
|
textAlign="right"
|
|
style={{ fontWeight: 600, ...styles.tnum }}
|
|
valueProps={{
|
|
binding: trackingBudget.groupSumAmount(id),
|
|
type: 'financial',
|
|
}}
|
|
/>
|
|
{!group.is_income && (
|
|
<TrackingSheetCell
|
|
name="balance"
|
|
width="flex"
|
|
textAlign="right"
|
|
style={{
|
|
fontWeight: 600,
|
|
paddingRight: styles.monthRightPadding,
|
|
...styles.tnum,
|
|
}}
|
|
valueProps={{
|
|
binding: trackingBudget.groupBalance(id),
|
|
type: 'financial',
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
});
|
|
|
|
export const CategoryMonth = memo(function CategoryMonth({
|
|
month,
|
|
category,
|
|
editing,
|
|
onEdit,
|
|
onBudgetAction,
|
|
onShowActivity,
|
|
}: CategoryMonthProps) {
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const triggerRef = useRef(null);
|
|
const format = useFormat();
|
|
|
|
const [balanceMenuOpen, setBalanceMenuOpen] = useState(false);
|
|
const triggerBalanceMenuRef = useRef(null);
|
|
|
|
const onMenuAction = (...args: Parameters<typeof onBudgetAction>) => {
|
|
onBudgetAction(...args);
|
|
setBalanceMenuOpen(false);
|
|
setMenuOpen(false);
|
|
};
|
|
|
|
const { showUndoNotification } = useUndo();
|
|
|
|
const navigate = useNavigate();
|
|
|
|
const { schedule, scheduleStatus, isScheduleRecurring, description } =
|
|
useCategoryScheduleGoalTemplateIndicator({
|
|
category,
|
|
month,
|
|
});
|
|
|
|
const showScheduleIndicator = schedule && scheduleStatus;
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
backgroundColor: monthUtils.isCurrentMonth(month)
|
|
? theme.budgetCurrentMonth
|
|
: theme.budgetOtherMonth,
|
|
'& .hover-visible': {
|
|
opacity: 0,
|
|
transition: 'opacity .25s',
|
|
},
|
|
'&:hover .hover-visible, & .force-visible .hover-visible': {
|
|
opacity: 1,
|
|
},
|
|
'& .hover-expand': {
|
|
maxWidth: 0,
|
|
overflow: 'hidden',
|
|
transition: 'max-width 0s .25s',
|
|
},
|
|
'&:hover .hover-expand, & .hover-expand.force-visible': {
|
|
maxWidth: '300px',
|
|
overflow: 'visible',
|
|
transition: 'max-width 0s linear 0s',
|
|
},
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
}}
|
|
>
|
|
{!editing && (
|
|
<>
|
|
<View
|
|
style={{
|
|
paddingLeft: 3,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderTopWidth: 1,
|
|
borderBottomWidth: 1,
|
|
borderColor: theme.tableBorder,
|
|
}}
|
|
>
|
|
<NotesButton
|
|
id={`${category.id}-${month}`}
|
|
defaultColor={theme.pageTextLight}
|
|
/>
|
|
</View>
|
|
<View
|
|
className={`hover-expand ${menuOpen ? 'force-visible' : ''}`}
|
|
style={{
|
|
flexDirection: 'row',
|
|
flexShrink: 0,
|
|
paddingLeft: 3,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderTopWidth: 1,
|
|
borderBottomWidth: 1,
|
|
borderColor: theme.tableBorder,
|
|
}}
|
|
>
|
|
<Button
|
|
ref={triggerRef}
|
|
variant="bare"
|
|
onPress={() => setMenuOpen(true)}
|
|
style={{
|
|
padding: 3,
|
|
}}
|
|
>
|
|
<SvgCheveronDown
|
|
width={14}
|
|
height={14}
|
|
className="hover-visible"
|
|
/>
|
|
</Button>
|
|
|
|
<Popover
|
|
triggerRef={triggerRef}
|
|
isOpen={menuOpen}
|
|
onOpenChange={() => setMenuOpen(false)}
|
|
placement="bottom start"
|
|
>
|
|
<BudgetMenu
|
|
onCopyLastMonthAverage={() => {
|
|
onMenuAction(month, 'copy-single-last', {
|
|
category: category.id,
|
|
});
|
|
showUndoNotification({
|
|
message: t(`Budget set to last month's budget.`),
|
|
});
|
|
}}
|
|
onSetMonthsAverage={numberOfMonths => {
|
|
if (
|
|
numberOfMonths !== 3 &&
|
|
numberOfMonths !== 6 &&
|
|
numberOfMonths !== 12
|
|
) {
|
|
return;
|
|
}
|
|
|
|
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
|
|
category: category.id,
|
|
});
|
|
showUndoNotification({
|
|
message: t(
|
|
'Budget set to {{numberOfMonths}}-month average.',
|
|
{ numberOfMonths },
|
|
),
|
|
});
|
|
}}
|
|
onApplyBudgetTemplate={() => {
|
|
onMenuAction(month, 'apply-single-category-template', {
|
|
category: category.id,
|
|
});
|
|
showUndoNotification({
|
|
message: t(`Budget template applied.`),
|
|
});
|
|
}}
|
|
/>
|
|
</Popover>
|
|
</View>
|
|
</>
|
|
)}
|
|
<TrackingSheetCell
|
|
name="budget"
|
|
exposed={editing}
|
|
focused={editing}
|
|
width="flex"
|
|
onExpose={() => onEdit(category.id, month)}
|
|
style={{ ...(editing && { zIndex: 100 }), ...styles.tnum }}
|
|
textAlign="right"
|
|
valueStyle={{
|
|
cursor: 'default',
|
|
margin: 1,
|
|
padding: '0 4px',
|
|
borderRadius: 4,
|
|
':hover': {
|
|
boxShadow: 'inset 0 0 0 1px ' + theme.pageTextSubdued,
|
|
backgroundColor: theme.budgetCurrentMonth,
|
|
},
|
|
}}
|
|
valueProps={{
|
|
binding: trackingBudget.catBudgeted(category.id),
|
|
type: 'financial',
|
|
getValueStyle: makeAmountGrey,
|
|
formatExpr: format.forEdit,
|
|
unformatExpr: format.fromEdit,
|
|
}}
|
|
inputProps={{
|
|
onBlur: () => {
|
|
onEdit(null);
|
|
},
|
|
style: {
|
|
backgroundColor: theme.budgetCurrentMonth,
|
|
},
|
|
}}
|
|
onSave={(parsedIntegerAmount: number | null) => {
|
|
onBudgetAction(month, 'budget-amount', {
|
|
category: category.id,
|
|
amount: parsedIntegerAmount ?? 0,
|
|
});
|
|
}}
|
|
/>
|
|
</View>
|
|
<Field name="spent" width="flex" style={{ textAlign: 'right' }}>
|
|
<View
|
|
data-testid="category-month-spent"
|
|
onClick={() => onShowActivity(category.id, month)}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: showScheduleIndicator
|
|
? 'space-between'
|
|
: 'flex-end',
|
|
gap: 2,
|
|
}}
|
|
>
|
|
{showScheduleIndicator && (
|
|
<View title={description}>
|
|
<Button
|
|
variant="bare"
|
|
style={{
|
|
color:
|
|
scheduleStatus === 'missed'
|
|
? theme.budgetNumberNegative
|
|
: scheduleStatus === 'due'
|
|
? theme.templateNumberUnderFunded
|
|
: theme.upcomingText,
|
|
}}
|
|
onPress={() =>
|
|
schedule._account
|
|
? navigate(`/accounts/${schedule._account}`)
|
|
: navigate('/accounts')
|
|
}
|
|
>
|
|
{isScheduleRecurring ? (
|
|
<SvgArrowsSynchronize style={{ width: 12, height: 12 }} />
|
|
) : (
|
|
<SvgCalendar3 style={{ width: 12, height: 12 }} />
|
|
)}
|
|
</Button>
|
|
</View>
|
|
)}
|
|
<TrackingCellValue
|
|
binding={trackingBudget.catSumAmount(category.id)}
|
|
type="financial"
|
|
>
|
|
{props => (
|
|
<CellValueText
|
|
{...props}
|
|
className={css({
|
|
cursor: 'pointer',
|
|
':hover': {
|
|
textDecoration: 'underline',
|
|
},
|
|
...makeAmountGrey(props.value),
|
|
})}
|
|
/>
|
|
)}
|
|
</TrackingCellValue>
|
|
</View>
|
|
</Field>
|
|
|
|
{!category.is_income && (
|
|
<Field
|
|
name="balance"
|
|
width="flex"
|
|
style={{ paddingRight: styles.monthRightPadding, textAlign: 'right' }}
|
|
>
|
|
<Button
|
|
variant="bare"
|
|
ref={triggerBalanceMenuRef}
|
|
onPress={() => !category.is_income && setBalanceMenuOpen(true)}
|
|
style={{
|
|
justifyContent: 'flex-end',
|
|
background: 'transparent',
|
|
width: '100%',
|
|
padding: 0,
|
|
}}
|
|
>
|
|
<BalanceWithCarryover
|
|
isDisabled={category.is_income}
|
|
carryover={trackingBudget.catCarryover(category.id)}
|
|
balance={trackingBudget.catBalance(category.id)}
|
|
goal={trackingBudget.catGoal(category.id)}
|
|
budgeted={trackingBudget.catBudgeted(category.id)}
|
|
longGoal={trackingBudget.catLongGoal(category.id)}
|
|
/>
|
|
</Button>
|
|
|
|
<Popover
|
|
triggerRef={triggerBalanceMenuRef}
|
|
isOpen={balanceMenuOpen}
|
|
onOpenChange={() => setBalanceMenuOpen(false)}
|
|
placement="bottom end"
|
|
>
|
|
<BalanceMenu
|
|
categoryId={category.id}
|
|
onCarryover={carryover => {
|
|
onMenuAction(month, 'carryover', {
|
|
category: category.id,
|
|
flag: carryover,
|
|
});
|
|
}}
|
|
/>
|
|
</Popover>
|
|
</Field>
|
|
)}
|
|
</View>
|
|
);
|
|
});
|
|
|
|
export { BudgetSummary } from './budgetsummary/BudgetSummary';
|
|
|
|
export const ExpenseGroupMonth = GroupMonth;
|
|
export const ExpenseCategoryMonth = CategoryMonth;
|
|
|
|
export const IncomeGroupMonth = GroupMonth;
|
|
export const IncomeCategoryMonth = CategoryMonth;
|