import React, { type ComponentProps, type ComponentType, type ReactNode, useState, type SVGProps, } from 'react'; import { css } from 'glamor'; import { reportBudget } from 'loot-core/src/client/queries'; import * as monthUtils from 'loot-core/src/shared/months'; import useFeatureFlag from '../../../hooks/useFeatureFlag'; import DotsHorizontalTriple from '../../../icons/v1/DotsHorizontalTriple'; import ArrowButtonDown1 from '../../../icons/v2/ArrowButtonDown1'; import ArrowButtonUp1 from '../../../icons/v2/ArrowButtonUp1'; import { colors, type CSSProperties, styles } from '../../../style'; import AlignedText from '../../common/AlignedText'; import Button from '../../common/Button'; import HoverTarget from '../../common/HoverTarget'; import Menu from '../../common/Menu'; import Stack from '../../common/Stack'; import Text from '../../common/Text'; import View from '../../common/View'; import NotesButton from '../../NotesButton'; import PrivacyFilter from '../../PrivacyFilter'; import CellValue from '../../spreadsheet/CellValue'; import NamespaceContext from '../../spreadsheet/NamespaceContext'; import useFormat from '../../spreadsheet/useFormat'; import useSheetValue from '../../spreadsheet/useSheetValue'; import { Tooltip } from '../../tooltips'; import { MONTH_BOX_SHADOW } from '../constants'; import { makeAmountFullStyle } from '../util'; import { useReport } from './ReportContext'; type PieProgressProps = { style?: SVGProps<SVGSVGElement>['style']; progress: number; color: string; backgroundColor: string; }; function PieProgress({ style, progress, color, backgroundColor, }: PieProgressProps) { let radius = 4; let circum = 2 * Math.PI * radius; let dash = progress * circum; let gap = circum; return ( <svg viewBox="0 0 20 20" style={style}> <circle r="10" cx="10" cy="10" fill={backgroundColor} /> <circle r={radius} cx="10" cy="10" fill="none" stroke={color} strokeWidth={radius * 2} strokeDasharray={`${dash} ${gap}`} transform="rotate(-90) translate(-20)" />{' '} </svg> ); } function fraction(num, denom) { if (denom === 0) { if (num > 0) { return 1; } return 0; } return num / denom; } type IncomeProgressProps = { current: ComponentProps<typeof CellValue>['binding']; target: ComponentProps<typeof CellValue>['binding']; }; function IncomeProgress({ current, target }: IncomeProgressProps) { let totalIncome = useSheetValue(current) || 0; let totalBudgeted = useSheetValue(target) || 0; let over = false; if (totalIncome < 0) { over = true; totalIncome = -totalIncome; } let frac = fraction(totalIncome, totalBudgeted); return ( <PieProgress progress={frac} color={over ? colors.r7 : colors.g5} backgroundColor={over ? colors.r10 : colors.n10} style={{ width: 20, height: 20 }} /> ); } type ExpenseProgressProps = { current: ComponentProps<typeof CellValue>['binding']; target: ComponentProps<typeof CellValue>['binding']; }; function ExpenseProgress({ current, target }: ExpenseProgressProps) { let totalSpent = useSheetValue(current) || 0; let totalBudgeted = useSheetValue(target) || 0; // Reverse total spent, and also set a bottom boundary of 0 (in case // income goes into an expense category and it's "positive", don't // show that in the graph) totalSpent = Math.max(-totalSpent, 0); let frac; let over = false; if (totalSpent > totalBudgeted) { frac = (totalSpent - totalBudgeted) / totalBudgeted; over = true; } else { frac = fraction(totalSpent, totalBudgeted); } return ( <PieProgress progress={frac} color={over ? colors.r7 : colors.g5} backgroundColor={over ? colors.r10 : colors.n10} style={{ width: 20, height: 20 }} /> ); } type BudgetTotalProps = { title: ReactNode; current: ComponentProps<typeof CellValue>['binding']; target: ComponentProps<typeof CellValue>['binding']; ProgressComponent: ComponentType<{ current; target }>; style?: CSSProperties; }; function BudgetTotal({ title, current, target, ProgressComponent, style, }: BudgetTotalProps) { return ( <View style={{ lineHeight: 1.5, flexDirection: 'row', alignItems: 'center', fontSize: 14, ...style, }} > <ProgressComponent current={current} target={target} /> <View style={{ marginLeft: 10 }}> <View> <Text style={{ color: colors.n4 }}>{title}</Text> </View> <Text> <CellValue binding={current} type="financial" /> <Text style={{ color: colors.n6, fontStyle: 'italic' }}> {' of '} <CellValue binding={target} type="financial" style={styles.notFixed} /> </Text> </Text> </View> </View> ); } type IncomeTotalProps = { style?: CSSProperties; }; function IncomeTotal({ style }: IncomeTotalProps) { return ( <BudgetTotal title="Income" current={reportBudget.totalIncome} target={reportBudget.totalBudgetedIncome} ProgressComponent={IncomeProgress} style={style} /> ); } type ExpenseTotalProps = { style?: CSSProperties; }; function ExpenseTotal({ style }: ExpenseTotalProps) { return ( <BudgetTotal title="Expenses" current={reportBudget.totalSpent} target={reportBudget.totalBudgetedExpense} ProgressComponent={ExpenseProgress} style={style} /> ); } type SavedProps = { projected: boolean; style?: CSSProperties; }; function Saved({ projected, style }: SavedProps) { let budgetedSaved = useSheetValue(reportBudget.totalBudgetedSaved) || 0; let totalSaved = useSheetValue(reportBudget.totalSaved) || 0; let format = useFormat(); let saved = projected ? budgetedSaved : totalSaved; let isNegative = saved < 0; return ( <View style={{ alignItems: 'center', fontSize: 14, ...style }}> {projected ? ( <Text style={{ color: colors.n4 }}>Projected Savings:</Text> ) : ( <View style={{ color: colors.n4 }}> {isNegative ? 'Overspent:' : 'Saved:'} </View> )} <HoverTarget renderContent={() => { if (!projected) { let diff = totalSaved - budgetedSaved; return ( <Tooltip position="bottom-center" style={{ padding: 10, fontSize: 14 }} > <AlignedText left="Projected Savings:" right={ <Text style={{ ...makeAmountFullStyle(budgetedSaved), ...styles.tnum, }} > {format(budgetedSaved, 'financial-with-sign')} </Text> } /> <AlignedText left="Difference:" right={ <Text style={{ ...makeAmountFullStyle(diff), ...styles.tnum }} > {format(diff, 'financial-with-sign')} </Text> } /> </Tooltip> ); } return null; }} > <View className={`${css([ { fontSize: 25, color: projected ? colors.y3 : isNegative ? colors.r4 : colors.p5, }, ])}`} > <PrivacyFilter blurIntensity={7}> {format(saved, 'financial')} </PrivacyFilter> </View> </HoverTarget> </View> ); } type BudgetSummaryProps = { month?: string; }; export function BudgetSummary({ month }: BudgetSummaryProps) { let { currentMonth, summaryCollapsed: collapsed, onBudgetAction, onToggleSummaryCollapse, } = useReport(); const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); let [menuOpen, setMenuOpen] = useState(false); function onMenuOpen() { setMenuOpen(true); } function onMenuClose() { setMenuOpen(false); } let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1; return ( <View style={{ backgroundColor: 'white', boxShadow: MONTH_BOX_SHADOW, borderRadius: 6, marginLeft: 0, marginRight: 0, marginTop: 5, flex: 1, cursor: 'default', marginBottom: 5, overflow: 'hidden', '& .hover-visible': { opacity: 0, transition: 'opacity .25s', }, '&:hover .hover-visible': { opacity: 1, }, }} > <NamespaceContext.Provider value={monthUtils.sheetForMonth(month)}> <View style={{ padding: '0 13px', ...(collapsed ? { margin: '10px 0' } : { marginTop: 16 }), }} > <View style={{ position: 'absolute', left: 10, top: 0, }} > <Button type="bare" className="hover-visible" onClick={onToggleSummaryCollapse} > <ExpandOrCollapseIcon width={13} height={13} // The margin is to make it the exact same size as the dots button style={{ color: colors.n6, margin: 1 }} /> </Button> </View> <div className={`${css([ { textAlign: 'center', marginTop: 3, fontSize: 18, fontWeight: 500, textDecorationSkip: 'ink', }, currentMonth === month && { textDecoration: 'underline' }, ])}`} > {monthUtils.format(month, 'MMMM')} </div> <View style={{ position: 'absolute', right: 10, top: 0, flexDirection: 'row', alignItems: 'center', }} > <View> <NotesButton id={`budget-${month}`} width={15} height={15} tooltipPosition="bottom-right" defaultColor={colors.n6} /> </View> <View style={{ userSelect: 'none' }}> <Button type="bare" onClick={onMenuOpen}> <DotsHorizontalTriple width={15} height={15} style={{ color: colors.n5 }} /> </Button> {menuOpen && ( <Tooltip position="bottom-right" width={200} style={{ padding: 0 }} onClose={onMenuClose} > <Menu onMenuSelect={type => { onMenuClose(); onBudgetAction(month, type); }} items={[ { name: 'copy-last', text: 'Copy last month’s budget' }, { name: 'set-zero', text: 'Set budgets to zero' }, { name: 'set-3-avg', text: 'Set budgets to 3 month average', }, isGoalTemplatesEnabled && { name: 'check-templates', text: 'Check templates', }, isGoalTemplatesEnabled && { name: 'apply-goal-template', text: 'Apply budget template', }, isGoalTemplatesEnabled && { name: 'overwrite-goal-template', text: 'Overwrite with budget template', }, ]} /> </Tooltip> )} </View> </View> </View> {!collapsed && ( <Stack spacing={2} style={{ alignSelf: 'center', backgroundColor: colors.n11, borderRadius: 4, padding: '10px 15px', marginTop: 13, }} > <IncomeTotal /> <ExpenseTotal /> </Stack> )} {collapsed ? ( <View style={{ alignItems: 'center', padding: '10px 20px', justifyContent: 'space-between', backgroundColor: colors.n11, borderTop: '1px solid ' + colors.n10, }} > <Saved projected={month >= currentMonth} /> </View> ) : ( <Saved projected={month >= currentMonth} style={{ marginTop: 13, marginBottom: 20 }} /> )} </NamespaceContext.Provider> </View> ); }