diff --git a/packages/desktop-client/src/components/accounts/Header.js b/packages/desktop-client/src/components/accounts/Header.js index 1e7dcbbe858..0dc588443fc 100644 --- a/packages/desktop-client/src/components/accounts/Header.js +++ b/packages/desktop-client/src/components/accounts/Header.js @@ -239,7 +239,7 @@ export function AccountHeader({ )} - + dispatch({ type: 'select-field' })} + title={type && 'Filters'} + > + {type === 'reports' ? ( + + ) : ( + <> + {' '} + Filter + + )} + + ); +} + +export function FilterButton({ onApply, type }) { let filters = useFilters(); let { dateFormat } = useSelector(state => { @@ -418,12 +440,7 @@ export function FilterButton({ onApply }) { return ( - + {state.fieldsOpen && ( 0 && child > 0 && parent - child; + + setScrollWidth(!width ? 0 : width); + } + + if (graphType === 'AreaGraph') { + return ( + + ); + } + if (graphType === 'BarGraph') { + return ( + + ); + } + if (graphType === 'BarLineGraph') { + return ( + + ); + } + if (graphType === 'DonutGraph') { + return ( + + ); + } + if (graphType === 'LineGraph') { + return ( + + ); + } + if (graphType === 'StackedBarGraph') { + return ( + + ); + } + if (graphType === 'TableGraph') { + return ( + + + + + + + + ); + } +} diff --git a/packages/desktop-client/src/components/reports/Header.js b/packages/desktop-client/src/components/reports/Header.js index dca96e47e9b..1e8fc518501 100644 --- a/packages/desktop-client/src/components/reports/Header.js +++ b/packages/desktop-client/src/components/reports/Header.js @@ -1,3 +1,5 @@ +import { useLocation } from 'react-router-dom'; + import * as monthUtils from 'loot-core/src/shared/months'; import ArrowLeft from '../../icons/v1/ArrowLeft'; @@ -8,7 +10,7 @@ import Select from '../common/Select'; import View from '../common/View'; import { FilterButton, AppliedFilters } from '../filters/FiltersMenu'; -function validateStart(allMonths, start, end) { +export function validateStart(allMonths, start, end) { const earliest = allMonths[allMonths.length - 1].name; if (end < start) { end = monthUtils.addMonths(start, 6); @@ -16,7 +18,7 @@ function validateStart(allMonths, start, end) { return boundedRange(earliest, start, end); } -function validateEnd(allMonths, start, end) { +export function validateEnd(allMonths, start, end) { const earliest = allMonths[allMonths.length - 1].name; if (start > end) { start = monthUtils.subMonths(end, 6); @@ -35,13 +37,13 @@ function boundedRange(earliest, start, end) { return [start, end]; } -function getLatestRange(offset) { +export function getLatestRange(offset) { const end = monthUtils.currentMonth(); const start = monthUtils.subMonths(end, offset); return [start, end]; } -function getFullRange(allMonths) { +export function getFullRange(allMonths) { const start = allMonths[allMonths.length - 1].name; const end = monthUtils.currentMonth(); return [start, end]; @@ -61,7 +63,11 @@ function Header({ onDeleteFilter, onCondOpChange, headerPrefixItems, + selectGraph, }) { + let location = useLocation(); + let path = location.pathname; + return ( {title} - - {headerPrefixItems} - + {path !== '/reports/custom' && ( - - onChangeDates(...validateEnd(allMonths, start, newValue)) - } - value={end} - options={allMonths.map(({ name, pretty }) => [name, pretty])} - /> - + {headerPrefixItems} - {filters && } + + + onChangeDates(...validateEnd(allMonths, start, newValue)) + } + value={end} + options={allMonths.map(({ name, pretty }) => [name, pretty])} + /> + + + {filters && } - {show1Month && ( + {show1Month && ( + + )} - )} - - - - - + + + + + + )} {filters && filters.length > 0 && ( - {children} - - ); - if (to) { - return ( - - {content} - - ); - } - return content; -} +import CashFlowCard from './reports/CashFlowCard'; +import CategorySpendingCard from './reports/CategorySpendingCard'; +import CustomReportCard from './reports/CustomReportCard'; +import NetWorthCard from './reports/NetWorthCard'; +import SankeyCard from './reports/SankeyCard'; -function LoadingIndicator() { +export function LoadingIndicator() { return ( setIsCardHovered(true)); - const onCardHoverEnd = useCallback(() => setIsCardHovered(false)); - - const params = useMemo( - () => netWorthSpreadsheet(start, end, accounts), - [start, end, accounts], - ); - const data = useReport('net_worth', params); - - return ( - - - - - - Net Worth - - - - {data && ( - - - - {integerToCurrency(data.netWorth)} - - - - - - - )} - - - {data ? ( - - ) : ( - - )} - - - ); -} - -function CashFlowCard() { - const end = monthUtils.currentDay(); - const start = monthUtils.currentMonth() + '-01'; - - const params = useMemo(() => simpleCashFlow(start, end), [start, end]); - const data = useReport('cash_flow_simple', params); - const [isCardHovered, setIsCardHovered] = useState(false); - const onCardHover = useCallback(() => setIsCardHovered(true)); - const onCardHoverEnd = useCallback(() => setIsCardHovered(false)); - - const { graphData } = data || {}; - const expense = -(graphData?.expense || 0); - const income = graphData?.income || 0; - - return ( - - - - - - Cash Flow - - - - {data && ( - - - - - - )} - - - {data ? ( - - {(width, height, portalHost) => ( - - } - labelComponent={ - (y + 40 > height ? height - 40 : y)} - light={true} - forceActive={true} - style={{ - padding: 0, - }} - /> - } - padding={{ - top: 0, - bottom: 0, - left: 0, - right: 0, - }} - > - - Income - - - {integerToCurrency(income)} - - - - ), - labelPosition: 'left', - }, - ]} - labels={d => d.premadeLabel} - /> - - Expenses - - - {integerToCurrency(expense)} - - - - ), - labelPosition: 'right', - }, - ]} - labels={d => d.premadeLabel} - /> - - )} - - ) : ( - - )} - - - ); -} - -function CategorySpendingCard() { - const { list: categories = [] } = useCategories(); - - const end = monthUtils.currentDay(); - const start = monthUtils.subMonths(end, 3); - - const params = useMemo(() => { - return categorySpendingSpreadsheet( - start, - end, - 3, - categories.filter(category => !category.is_income && !category.hidden), - ); - }, [start, end, categories]); - - const perCategorySpending = useReport('category_spending', params); - - return ( - - - - - - Spending - - - - - - - {perCategorySpending ? ( - - ) : ( - - )} - - ); -} - -function SankeyCard() { - const { grouped: categoryGroups } = useCategories(); - const end = monthUtils.currentMonth(); - const start = monthUtils.subMonths(end, 5); - - const params = useMemo( - () => sankeySpreadsheet(start, end, categoryGroups), - [start, end, categoryGroups], - ); - const data = useReport('sankey', params); - - return ( - - - - - Sankey - - - - - - {data ? ( - - ) : ( - - )} - - - ); -} - export default function Overview() { let categorySpendingReportFeatureFlag = useFeatureFlag( 'categorySpendingReport', ); let sankeyFeatureFlag = useFeatureFlag('sankeyReport'); + let customReportsFeatureFlag = useFeatureFlag('customReports'); + let accounts = useSelector(state => state.queries.accounts); return ( - - {(sankeyFeatureFlag || categorySpendingReportFeatureFlag) && ( - - {categorySpendingReportFeatureFlag && } - {sankeyFeatureFlag && } - {(!categorySpendingReportFeatureFlag || !sankeyFeatureFlag) && ( - <> -
-
- - )} - - )} + + {categorySpendingReportFeatureFlag && } + {sankeyFeatureFlag && } + {customReportsFeatureFlag ? ( + + ) : ( +
+ )} + {!categorySpendingReportFeatureFlag &&
} + {!sankeyFeatureFlag &&
} + ); } diff --git a/packages/desktop-client/src/components/reports/ReportCard.tsx b/packages/desktop-client/src/components/reports/ReportCard.tsx new file mode 100644 index 00000000000..0be8e567279 --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportCard.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { theme } from '../../style'; +import AnchorLink from '../common/AnchorLink'; +import View from '../common/View'; + +export default function ReportCard({ flex, to, style, children }) { + const containerProps = { flex, margin: 15 }; + + const content = ( + + {children} + + ); + + if (to) { + return ( + + {content} + + ); + } + return content; +} diff --git a/packages/desktop-client/src/components/reports/ReportOptions.tsx b/packages/desktop-client/src/components/reports/ReportOptions.tsx new file mode 100644 index 00000000000..a310a5f026d --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportOptions.tsx @@ -0,0 +1,44 @@ +const balanceTypeOptions = [ + { description: 'Expense', format: 'totalDebts' }, + { description: 'Income', format: 'totalAssets' }, + { description: 'Net', format: 'totalTotals' }, +]; + +const groupByOptions = [ + { description: 'Category' }, + { description: 'Group' }, + { description: 'Payee' }, + { description: 'Account' }, + { description: 'Month' }, + { description: 'Year' }, +]; + +const dateRangeOptions = [ + { description: '1 month', name: 1 }, + { description: '3 months', name: 2 }, + { description: '6 months', name: 5 }, + { description: '1 year', name: 11 }, + { description: 'All time', name: 'allMonths' }, +]; + +export const ReportOptions = { + groupBy: groupByOptions, + balanceType: balanceTypeOptions, + balanceTypeMap: new Map( + balanceTypeOptions.map(item => [item.description, item.format]), + ), + dateRange: dateRangeOptions, + dateRangeMap: new Map( + dateRangeOptions.map(item => [item.description, item.name]), + ), +}; + +/* +const intervalOptions = [ +{ value: 1, description: 'Daily', name: 1, +{ value: 2, description: 'Weekly', name: 2, +{ value: 3, description: 'Fortnightly', name: 3, +{ value: 4, description: 'Monthly', name: 4, +{ value: 5, description: 'Yearly', name: 5, +]; +*/ diff --git a/packages/desktop-client/src/components/reports/ReportRouter.js b/packages/desktop-client/src/components/reports/ReportRouter.js index 868889e3484..088c6942cfe 100644 --- a/packages/desktop-client/src/components/reports/ReportRouter.js +++ b/packages/desktop-client/src/components/reports/ReportRouter.js @@ -1,11 +1,12 @@ import React from 'react'; import { Route, Routes } from 'react-router-dom'; -import CashFlow from './CashFlow'; -import CategorySpending from './CategorySpending'; -import NetWorth from './NetWorth'; import Overview from './Overview'; -import Sankey from './Sankey'; +import CashFlow from './reports/CashFlow'; +import CategorySpending from './reports/CategorySpending'; +import CustomReport from './reports/CustomReport'; +import NetWorth from './reports/NetWorth'; +import Sankey from './reports/Sankey'; export function ReportRouter() { return ( @@ -14,6 +15,7 @@ export function ReportRouter() { } /> } /> } /> + } /> } /> ); diff --git a/packages/desktop-client/src/components/reports/ReportSidebar.js b/packages/desktop-client/src/components/reports/ReportSidebar.js new file mode 100644 index 00000000000..8a43f59d648 --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportSidebar.js @@ -0,0 +1,411 @@ +import React from 'react'; + +import * as monthUtils from 'loot-core/src/shared/months'; + +import { theme } from '../../style'; +import Button from '../common/Button'; +import Select from '../common/Select'; +import Text from '../common/Text'; +import View from '../common/View'; +import { Checkbox } from '../forms'; + +import CategorySelector from './CategorySelector'; +import { + validateStart, + validateEnd, + getLatestRange, + getFullRange, +} from './Header'; +import { ReportOptions } from './ReportOptions'; + +function ModeButton({ selected, children, style, onSelect }) { + return ( + + ); +} + +export function ReportSidebar({ + start, + end, + onChangeDates, + dateRange, + setDateRange, + dateRangeLine, + allMonths, + graphType, + setGraphType, + setViewLegend, + typeDisabled, + setTypeDisabled, + groupBy, + setGroupBy, + balanceType, + setBalanceType, + mode, + setMode, + empty, + setEmpty, + hidden, + setHidden, + uncat, + setUncat, + categories, + selectedCategories, + setSelectedCategories, +}) { + function onChangeMode(cond) { + setMode(cond); + if (cond === 'time') { + if (graphType === 'TableGraph') { + setTypeDisabled([]); + } else { + setTypeDisabled(['Net']); + if (['Net'].includes(balanceType)) { + setBalanceType('Expense'); + } + } + if (graphType === 'BarGraph') { + setGraphType('StackedBarGraph'); + } + if (['AreaGraph', 'DonutGraph'].includes(graphType)) { + setGraphType('TableGraph'); + //setViewLegend(false); + } + if (['Month', 'Year'].includes(groupBy)) { + setGroupBy('Category'); + } + } else { + if (graphType === 'StackedBarGraph') { + setGraphType('BarGraph'); + } else { + setTypeDisabled([]); + } + } + } + + function onChangeSplit(cond) { + setGroupBy(cond); + if (mode === 'total') { + if (graphType !== 'TableGraph') { + setTypeDisabled(!['Month', 'Year'].includes(groupBy) ? [] : ['Net']); + } + } + if (['Net'].includes(balanceType) && graphType !== 'TableGraph') { + setBalanceType('Expense'); + } + } + + return ( + + + + + Display + + + + + Mode: + + onChangeMode('total')} + > + Total + + onChangeMode('time')} + > + Time + + + + + Split: + + [ + option.description, + option.description, + ])} + disabledKeys={typeDisabled} + /> + + {/* //It would be nice to retain this for future usage + + + Interval: + + { + setDateRange(e); + if (e === 'allMonths') { + onChangeDates(...getFullRange(allMonths)); + } else { + onChangeDates( + ...getLatestRange(ReportOptions.dateRangeMap.get(e)), + ); + } + }} + options={ReportOptions.dateRange.map(option => [ + option.description, + option.description, + ])} + line={dateRangeLine} + /> + + + + From: + + + onChangeDates(...validateEnd(allMonths, start, newValue)) + } + value={end} + options={allMonths.map(({ name, pretty }) => [name, pretty])} + /> + + + + {['Category', 'Group'].includes(groupBy) && ( + + + + )} + + ); +} diff --git a/packages/desktop-client/src/components/reports/ReportSummary.js b/packages/desktop-client/src/components/reports/ReportSummary.js new file mode 100644 index 00000000000..562de586050 --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportSummary.js @@ -0,0 +1,197 @@ +import React from 'react'; + +import * as monthUtils from 'loot-core/src/shared/months'; +import { + amountToCurrency, + integerToCurrency, + amountToInteger, +} from 'loot-core/src/shared/util'; + +import { theme, styles } from '../../style'; +import Text from '../common/Text'; +import View from '../common/View'; +import PrivacyFilter from '../PrivacyFilter'; + +export function ReportSummary({ + start, + end, + data, + balanceTypeOp, + monthsCount, +}) { + let net = data.totalDebts > data.totalAssets ? 'EXPENSE' : 'INCOME'; + const average = amountToInteger(data[balanceTypeOp]) / monthsCount; + return ( + + + + {monthUtils.format(start, 'MMM yyyy')} -{' '} + {monthUtils.format(end, 'MMM yyyy')} + + + + + {balanceTypeOp === 'totalDebts' + ? 'TOTAL SPENDING' + : balanceTypeOp === 'totalAssets' + ? 'TOTAL INCOME' + : 'NET ' + net} + + + + {amountToCurrency(data[balanceTypeOp])} + + + For this time period + + + + {balanceTypeOp === 'totalDebts' + ? 'AVERAGE SPENDING' + : balanceTypeOp === 'totalAssets' + ? 'AVERAGE INCOME' + : 'AVERAGE NET'} + + + + {integerToCurrency(Math.round(average))} + + + Per month + + + ); +} + +export function ReportLegend({ data, legend, groupBy }) { + return ( + + + {groupBy} + + + {legend.map(item => { + return ( + + + + {item.name} + + + ); + })} + + + ); +} diff --git a/packages/desktop-client/src/components/reports/ReportTable.tsx b/packages/desktop-client/src/components/reports/ReportTable.tsx new file mode 100644 index 00000000000..fd7f8999ef0 --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportTable.tsx @@ -0,0 +1,35 @@ +import React, { useLayoutEffect, useRef } from 'react'; + +import View from '../common/View'; + +export default function ReportTable({ saveScrollWidth, style, children }) { + let contentRef = useRef(); + + useLayoutEffect(() => { + if (contentRef.current && saveScrollWidth) { + saveScrollWidth( + contentRef.current.offsetParent + ? contentRef.current.parentElement.offsetWidth + : 0, + contentRef.current ? contentRef.current.offsetWidth : 0, + ); + } + }); + + return ( + + +
{children}
+
+
+ ); +} diff --git a/packages/desktop-client/src/components/reports/ReportTableHeader.tsx b/packages/desktop-client/src/components/reports/ReportTableHeader.tsx new file mode 100644 index 00000000000..932d7e4a341 --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportTableHeader.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import * as d from 'date-fns'; + +import { theme } from '../../style'; +import { Row, Cell } from '../table'; + +export default function ReportTableHeader({ + scrollWidth, + groupBy, + interval, + balanceType, +}) { + return ( + + + {interval + ? interval.map(header => { + return ( + + ); + }) + : balanceType === 'Net' && ( + <> + + + + )} + + + {scrollWidth > 0 && } + + ); +} diff --git a/packages/desktop-client/src/components/reports/ReportTableList.tsx b/packages/desktop-client/src/components/reports/ReportTableList.tsx new file mode 100644 index 00000000000..216e6fef499 --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportTableList.tsx @@ -0,0 +1,237 @@ +import React, { memo } from 'react'; + +import { + amountToCurrency, + amountToInteger, + integerToCurrency, +} from 'loot-core/src/shared/util'; + +import { theme } from '../../style'; +import View from '../common/View'; +import { Row, Cell } from '../table'; + +type TableRowProps = { + item: { + date: string; + name: string; + monthData: []; + totalAssets: number; + totalDebts: number; + }; + balanceTypeOp?: string | null; + groupByItem: string; + mode: string; + monthsCount: number; + style?: object | null; +}; + +const TableRow = memo( + ({ + item, + balanceTypeOp, + groupByItem, + mode, + monthsCount, + style, + }: TableRowProps) => { + const average = amountToInteger(item[balanceTypeOp]) / monthsCount; + return ( + + 12 && item[groupByItem]} + style={{ + minWidth: 125, + }} + /> + {item.monthData && mode === 'time' + ? item.monthData.map(month => { + return ( + 100000 && + amountToCurrency(month[balanceTypeOp]) + } + width="flex" + privacyFilter + /> + ); + }) + : balanceTypeOp === 'totalTotals' && ( + <> + 100000 && + amountToCurrency(item.totalAssets) + } + width="flex" + style={{ + minWidth: 85, + }} + /> + 100000 && + amountToCurrency(item.totalDebts) + } + width="flex" + style={{ + minWidth: 85, + }} + /> + + )} + 100000 && + amountToCurrency(item[balanceTypeOp]) + } + style={{ + fontWeight: 600, + minWidth: 85, + }} + width="flex" + privacyFilter + /> + 100000 && + integerToCurrency(Math.round(average)) + } + style={{ + fontWeight: 600, + minWidth: 85, + }} + width="flex" + privacyFilter + /> + + ); + }, +); + +function GroupedTableRow({ + item, + balanceTypeOp, + groupByItem, + mode, + monthsCount, + empty, +}) { + return ( + <> + + + {item.categories + .filter(i => + !empty + ? balanceTypeOp === 'totalTotals' + ? i.totalAssets !== 0 || + i.totalDebts !== 0 || + i.totalTotals !== 0 + : i[balanceTypeOp] !== 0 + : true, + ) + .map(cat => { + return ( + + ); + })} + + + + ); +} + +export default function ReportTableList({ + data, + empty, + monthsCount, + balanceTypeOp, + mode, + groupBy, +}) { + const groupByItem = ['Month', 'Year'].includes(groupBy) ? 'date' : 'name'; + const groupByData = + groupBy === 'Category' + ? 'groupData' + : ['Month', 'Year'].includes(groupBy) + ? 'monthData' + : 'data'; + + return ( + + {data[groupByData] + .filter(i => + !empty + ? balanceTypeOp === 'totalTotals' + ? i.totalAssets !== 0 || i.totalDebts !== 0 || i.totalTotals !== 0 + : i[balanceTypeOp] !== 0 + : true, + ) + .map(item => { + if (groupBy === 'Category') { + return ( + + ); + } else { + return ( + + ); + } + })} + + ); +} diff --git a/packages/desktop-client/src/components/reports/ReportTableTotals.tsx b/packages/desktop-client/src/components/reports/ReportTableTotals.tsx new file mode 100644 index 00000000000..5f37740a9c5 --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportTableTotals.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import { + amountToCurrency, + amountToInteger, + integerToCurrency, +} from 'loot-core/src/shared/util'; + +import { theme } from '../../style'; +import { Row, Cell } from '../table'; + +export default function ReportTableTotals({ + data, + scrollWidth, + balanceTypeOp, + mode, + monthsCount, +}) { + const average = amountToInteger(data[balanceTypeOp]) / monthsCount; + return ( + + + {mode === 'time' + ? data.monthData.map(item => { + return ( + 100000 && + amountToCurrency(item[balanceTypeOp]) + } + width="flex" + privacyFilter + /> + ); + }) + : balanceTypeOp === 'totalTotals' && ( + <> + 100000 && + amountToCurrency(data.totalAssets) + } + width="flex" + /> + 100000 && + amountToCurrency(data.totalDebts) + } + width="flex" + /> + + )} + 100000 && + amountToCurrency(data[balanceTypeOp]) + } + width="flex" + privacyFilter + /> + 100000 && + integerToCurrency(Math.round(average)) + } + width="flex" + privacyFilter + /> + + {scrollWidth > 0 && } + + ); +} diff --git a/packages/desktop-client/src/components/reports/ReportTopbar.js b/packages/desktop-client/src/components/reports/ReportTopbar.js new file mode 100644 index 00000000000..9f675a347b8 --- /dev/null +++ b/packages/desktop-client/src/components/reports/ReportTopbar.js @@ -0,0 +1,179 @@ +import React from 'react'; + +import Calculator from '../../icons/v1/Calculator'; +import Chart from '../../icons/v1/Chart'; +import ChartBar from '../../icons/v1/ChartBar'; +import ChartPie from '../../icons/v1/ChartPie'; +import ListBullet from '../../icons/v1/ListBullet'; +import Queue from '../../icons/v1/Queue'; +import Tag from '../../icons/v1/Tag'; +import { theme } from '../../style'; +import Button from '../common/Button'; +import View from '../common/View'; +import { FilterButton } from '../filters/FiltersMenu'; + +import { SaveReportMenuButton } from './SaveReport'; + +function GraphButton({ selected, children, style, onSelect, title, disabled }) { + return ( + + ); +} + +export function ReportTopbar({ + graphType, + setGraphType, + mode, + viewLegend, + setViewLegend, + setTypeDisabled, + balanceType, + setBalanceType, + groupBy, + setGroupBy, + viewSummary, + setViewSummary, + viewLabels, + setViewLabels, + onApplyFilter, +}) { + return ( + + { + setGraphType('TableGraph'); + //setViewLegend(false); + setTypeDisabled([]); + }} + > + + + { + if (mode === 'total') { + setGraphType('BarGraph'); + if (['Net'].includes(balanceType)) { + setBalanceType('Expense'); + } + setTypeDisabled(['Month', 'Year'].includes(groupBy) ? [] : ['Net']); + } else { + setGraphType('StackedBarGraph'); + setTypeDisabled(['Net']); + setBalanceType('Expense'); + } + }} + style={{ marginLeft: 15 }} + > + + + { + setGraphType('AreaGraph'); + setGroupBy('Month'); + //setViewLegend(false); + setTypeDisabled([]); + }} + style={{ marginLeft: 15 }} + disabled={mode === 'total' ? false : true} + > + + + { + setGraphType('DonutGraph'); + setTypeDisabled(['Net']); + setBalanceType('Expense'); + }} + style={{ marginLeft: 15 }} + disabled={mode === 'total' ? false : true} + > + + + + { + setViewLegend(!viewLegend); + }} + style={{ marginLeft: 15 }} + title="Show Legend" + disabled={ + true //descoping for future PR + //graphType === 'TableGraph' || graphType === 'AreaGraph' ? true : false + } + > + + + { + setViewSummary(!viewSummary); + }} + style={{ marginLeft: 15 }} + title="Show Summary" + > + + + { + setViewLabels(!viewLabels); + }} + style={{ marginLeft: 15 }} + title="Show labels" + disabled={true} + > + + + + + + + + ); +} diff --git a/packages/desktop-client/src/components/reports/SaveReport.tsx b/packages/desktop-client/src/components/reports/SaveReport.tsx new file mode 100644 index 00000000000..b6303870ace --- /dev/null +++ b/packages/desktop-client/src/components/reports/SaveReport.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; + +import ExpandArrow from '../../icons/v0/ExpandArrow'; +import Button from '../common/Button'; +import Menu from '../common/Menu'; +import MenuTooltip from '../common/MenuTooltip'; +import Text from '../common/Text'; +import View from '../common/View'; + +function SaveReportMenu({ setMenuOpen }) { + return ( + setMenuOpen(false)}> + { + switch (item) { + case 'save': + case 'clear': + setMenuOpen(false); + break; + default: + } + }} + items={[ + { + name: 'save', + text: 'Save new report', + disabled: true, + }, + { + name: 'clear', + text: 'Clear all', + disabled: true, + }, + ]} + /> + + ); +} + +export function SaveReportMenuButton() { + let [menuOpen, setMenuOpen] = useState(false); + + return ( + + + {menuOpen && } + + ); +} diff --git a/packages/desktop-client/src/components/reports/chart-theme.js b/packages/desktop-client/src/components/reports/chart-theme.js index b8e1c2bd9ef..391786a17c6 100644 --- a/packages/desktop-client/src/components/reports/chart-theme.js +++ b/packages/desktop-client/src/components/reports/chart-theme.js @@ -111,3 +111,27 @@ export const chartTheme = { }, }, }; + +export function getColorScale(name) { + const scales = { + grayscale: ['#cccccc', '#969696', '#636363', '#252525'], + qualitative: [ + '#45B29D', //Dark Teal + '#EFC94C', //Yellow + '#E27A3F', //Orange + '#DF5A49', //Light Red + '#5F91B8', //Blue + '#E2A37F', //Peach + '#55DBC1', //Light Teal + '#EFDA97', //Light Yellow + '#DF948A', //Light Red + ], + heatmap: ['#428517', '#77D200', '#D6D305', '#EC8E19', '#C92B05'], + warm: ['#940031', '#C43343', '#DC5429', '#FF821D', '#FFAF55'], + cool: ['#2746B9', '#0B69D4', '#2794DB', '#31BB76', '#60E83B'], + red: ['#FCAE91', '#FB6A4A', '#DE2D26', '#A50F15', '#750B0E'], + blue: ['#002C61', '#004B8F', '#006BC9', '#3795E5', '#65B4F4'], + green: ['#354722', '#466631', '#649146', '#8AB25C', '#A9C97E'], + }; + return name ? scales[name] : scales.grayscale; +} diff --git a/packages/desktop-client/src/components/reports/graphs/AreaGraph.tsx b/packages/desktop-client/src/components/reports/graphs/AreaGraph.tsx new file mode 100644 index 00000000000..18cbf71b7d9 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/AreaGraph.tsx @@ -0,0 +1,191 @@ +import React from 'react'; + +import { css } from 'glamor'; +import { + AreaChart, + Area, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +import { amountToCurrency } from 'loot-core/src/shared/util'; + +import { theme } from '../../../style'; +import { type CSSProperties } from '../../../style'; +import AlignedText from '../../common/AlignedText'; +import PrivacyFilter from '../../PrivacyFilter'; +import Container from '../Container'; +import numberFormatterTooltip from '../numberFormatter'; + +type PayloadItem = { + payload: { + date: string; + totalAssets: number | string; + totalDebts: number | string; + totalTotals: number | string; + }; +}; + +type CustomTooltipProps = { + active?: boolean; + payload?: PayloadItem[]; + balanceTypeOp?: string; +}; + +const CustomTooltip = ({ + active, + payload, + balanceTypeOp, +}: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( +
+
+
+ {payload[0].payload.date} +
+
+ + {['totalAssets', 'totalTotals'].includes(balanceTypeOp) && ( + + )} + {['totalDebts', 'totalTotals'].includes(balanceTypeOp) && ( + + )} + {['totalTotals'].includes(balanceTypeOp) && ( + + {amountToCurrency(payload[0].payload.totalTotals)} + + } + /> + )} + +
+
+
+ ); + } +}; + +type AreaGraphProps = { + style?: CSSProperties; + data; + balanceTypeOp; + compact: boolean; + domain?: { + totalTotals?: [number, number]; + }; +}; + +function AreaGraph({ style, data, balanceTypeOp, compact }: AreaGraphProps) { + const tickFormatter = tick => { + return `${Math.round(tick).toLocaleString()}`; // Formats the tick values as strings with commas + }; + + const gradientOffset = () => { + const dataMax = Math.max(...data.monthData.map(i => i[balanceTypeOp])); + const dataMin = Math.min(...data.monthData.map(i => i[balanceTypeOp])); + + if (dataMax <= 0) { + return 0; + } + if (dataMin >= 0) { + return 1; + } + + return dataMax / (dataMax - dataMin); + }; + + const off = gradientOffset(); + + return ( + + {(width, height, portalHost) => + data.monthData && ( + +
+ {!compact &&
} + + {compact ? null : ( + + )} + {compact ? null : } + {compact ? null : ( + + )} + } + formatter={numberFormatterTooltip} + isAnimationActive={false} + /> + + + + + + + + + +
+ + ) + } + + ); +} + +export default AreaGraph; diff --git a/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx b/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx new file mode 100644 index 00000000000..5b3e75b9a84 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx @@ -0,0 +1,251 @@ +import React from 'react'; + +import { css } from 'glamor'; +import { + BarChart, + Bar, + CartesianGrid, + //Legend, + Cell, + ReferenceLine, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +import { amountToCurrency } from 'loot-core/src/shared/util'; + +import { theme } from '../../../style'; +import { type CSSProperties } from '../../../style'; +import AlignedText from '../../common/AlignedText'; +import PrivacyFilter from '../../PrivacyFilter'; +import { getColorScale } from '../chart-theme'; +import Container from '../Container'; +import numberFormatterTooltip from '../numberFormatter'; + +type PayloadChild = { + props: { + name: string; + fill: string; + }; +}; + +type PayloadItem = { + value: string; + payload: { + name: string; + totalAssets: number | string; + totalDebts: number | string; + totalTotals: number | string; + networth: number | string; + totalChange: number | string; + children: [PayloadChild]; + }; +}; + +type CustomTooltipProps = { + active?: boolean; + payload?: PayloadItem[]; + balanceTypeOp?: string; + yAxis?: string; +}; + +const CustomTooltip = ({ + active, + payload, + balanceTypeOp, + yAxis, +}: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( +
+
+
+ {payload[0].payload[yAxis]} +
+
+ + {['totalAssets', 'totalTotals'].includes(balanceTypeOp) && ( + + )} + {['totalDebts', 'totalTotals'].includes(balanceTypeOp) && ( + + )} + {['totalTotals'].includes(balanceTypeOp) && ( + + {amountToCurrency(payload[0].payload.totalTotals)} + + } + /> + )} + +
+
+
+ ); + } +}; + +/* Descoped for future PR +type CustomLegendProps = { + active?: boolean; + payload?: PayloadItem[]; + label?: string; +}; + +const CustomLegend = ({ active, payload, label }: CustomLegendProps) => { + const agg = payload[0].payload.children.map(leg => { + return { + name: leg.props.name, + color: leg.props.fill, + }; + }); + + OnChangeLegend(agg); + + return
; +}; +*/ + +type BarGraphProps = { + style?: CSSProperties; + data; + groupBy; + balanceTypeOp; + empty; + compact: boolean; + domain?: { + y?: [number, number]; + }; +}; + +function BarGraph({ + style, + data, + groupBy, + empty, + balanceTypeOp, + compact, + domain, +}: BarGraphProps) { + const colorScale = getColorScale('qualitative'); + const yAxis = ['Month', 'Year'].includes(groupBy) ? 'date' : 'name'; + const splitData = ['Month', 'Year'].includes(groupBy) ? 'monthData' : 'data'; + + const getVal = obj => { + if (balanceTypeOp === 'totalDebts') { + return -1 * obj.totalDebts; + } else { + return obj.totalAssets; + } + }; + + const longestLabelLength = data[splitData] + .map(c => c[yAxis]) + .reduce((acc, cur) => (cur.length > acc ? cur.length : acc), 0); + + return ( + + {(width, height, portalHost) => + data[splitData] && ( + +
+ {!compact &&
} + + !empty ? i[balanceTypeOp] !== 0 : true, + )} + margin={{ top: 0, right: 0, left: 0, bottom: 0 }} + > + { + //!compact && } /> + } + + } + formatter={numberFormatterTooltip} + isAnimationActive={false} + /> + {!compact && } + {!compact && ( + + )} + {!compact && } + {!compact && } + getVal(val)} stackId="a"> + {data[splitData] + .filter(i => (!empty ? i[balanceTypeOp] !== 0 : true)) + .map((entry, index) => ( + + ))} + + {yAxis === 'date' && balanceTypeOp === 'totalTotals' && ( + + {data[splitData] + .filter(i => (!empty ? i[balanceTypeOp] !== 0 : true)) + .map((entry, index) => ( + + ))} + + )} + +
+ + ) + } + + ); +} + +export default BarGraph; diff --git a/packages/desktop-client/src/components/reports/graphs/BarLineGraph.tsx b/packages/desktop-client/src/components/reports/graphs/BarLineGraph.tsx new file mode 100644 index 00000000000..390fa5e5a10 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/BarLineGraph.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +import { css } from 'glamor'; +import { + ComposedChart, + Line, + Bar, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +import { theme } from '../../../style'; +import { type CSSProperties } from '../../../style'; +import AlignedText from '../../common/AlignedText'; +import PrivacyFilter from '../../PrivacyFilter'; +import Container from '../Container'; +import numberFormatterTooltip from '../numberFormatter'; + +type PayloadItem = { + payload: { + date: string; + assets: number | string; + debt: number | string; + networth: number | string; + change: number | string; + }; +}; + +type CustomTooltipProps = { + active?: boolean; + payload?: PayloadItem[]; + label?: string; +}; + +const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( +
+
+
+ {payload[0].payload.date} +
+
+ + + + {payload[0].payload.change}} + /> + +
+
+
+ ); + } +}; + +type BarLineGraphProps = { + style?: CSSProperties; + graphData; + compact: boolean; + domain?: { + y?: [number, number]; + }; +}; + +function BarLineGraph({ + style, + graphData, + compact, + domain, +}: BarLineGraphProps) { + const tickFormatter = tick => { + return `${Math.round(tick).toLocaleString()}`; // Formats the tick values as strings with commas + }; + + return ( + + {(width, height, portalHost) => + graphData && ( + +
+ {!compact &&
} + + } + formatter={numberFormatterTooltip} + isAnimationActive={false} + /> + + + + + + +
+ + ) + } + + ); +} + +export default BarLineGraph; diff --git a/packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx b/packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx index a74c5ebf7b5..671101561e0 100644 --- a/packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx @@ -5,9 +5,9 @@ import { VictoryAxis, VictoryBar, VictoryChart, VictoryStack } from 'victory'; import { chartTheme } from '../chart-theme'; import Container from '../Container'; +import { type CategorySpendingGraphData } from '../spreadsheets/category-spending-spreadsheet'; import Tooltip from '../Tooltip'; -import { type CategorySpendingGraphData } from './category-spending-spreadsheet'; import { Area } from './common'; type CategorySpendingGraphProps = { diff --git a/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx b/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx new file mode 100644 index 00000000000..41798b395a4 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx @@ -0,0 +1,175 @@ +import React from 'react'; + +import { css } from 'glamor'; +import { + PieChart, + Pie, + Cell, + //Legend, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +import { amountToCurrency } from 'loot-core/src/shared/util'; + +import { theme } from '../../../style'; +import { type CSSProperties } from '../../../style'; +import Text from '../../common/Text'; +import PrivacyFilter from '../../PrivacyFilter'; +import { getColorScale } from '../chart-theme'; +import Container from '../Container'; +import numberFormatterTooltip from '../numberFormatter'; + +type PayloadItem = { + name: string; + value: string; + color: string; + payload: { + date: string; + assets: number | string; + debt: number | string; + networth: number | string; + change: number | string; + fill: string; + }; +}; + +type CustomTooltipProps = { + active?: boolean; + payload?: PayloadItem[]; + label?: string; +}; + +const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( +
+
+
+ {payload[0].name} +
+
+ + + {amountToCurrency(payload[0].value)} + + +
+
+
+ ); + } +}; + +/* Descoped for future PR +type CustomLegendProps = { + active?: boolean; + payload?: PayloadItem[]; + label?: string; +}; + +const CustomLegend = ({ active, payload, label }: CustomLegendProps) => { + const agg = payload.map(leg => { + return { + name: leg.value, + color: leg.color, + }; + }); + + OnChangeLegend(agg); + + return
; +}; +*/ + +type DonutGraphProps = { + style?: CSSProperties; + data; + groupBy; + balanceTypeOp; + empty; + compact: boolean; + domain?: { + y?: [number, number]; + }; +}; + +function DonutGraph({ + style, + data, + groupBy, + empty, + balanceTypeOp, + compact, + domain, +}: DonutGraphProps) { + const colorScale = getColorScale('qualitative'); + const yAxis = ['Month', 'Year'].includes(groupBy) ? 'date' : 'name'; + const splitData = ['Month', 'Year'].includes(groupBy) ? 'monthData' : 'data'; + + const getVal = obj => { + if (balanceTypeOp === 'totalDebts') { + return -1 * obj[balanceTypeOp]; + } else { + return obj[balanceTypeOp]; + } + }; + + return ( + + {(width, height, portalHost) => + data[splitData] && ( + +
+ {!compact &&
} + + { + //} /> + } + } + formatter={numberFormatterTooltip} + isAnimationActive={false} + /> + getVal(val)} + nameKey={yAxis} + isAnimationActive={false} + data={data[splitData].filter(i => + !empty ? i[balanceTypeOp] !== 0 : true, + )} + innerRadius={Math.min(width, height) * 0.2} + fill="#8884d8" + > + {data[splitData].map((entry, index) => ( + + ))} + + +
+ + ) + } + + ); +} + +export default DonutGraph; diff --git a/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx b/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx new file mode 100644 index 00000000000..1c373de3936 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx @@ -0,0 +1,121 @@ +import React from 'react'; + +import { css } from 'glamor'; +import { + LineChart, + Line, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +import { theme } from '../../../style'; +import { type CSSProperties } from '../../../style'; +import AlignedText from '../../common/AlignedText'; +import PrivacyFilter from '../../PrivacyFilter'; +import Container from '../Container'; +import numberFormatterTooltip from '../numberFormatter'; + +type PayloadItem = { + payload: { + date: string; + assets: number | string; + debt: number | string; + networth: number | string; + change: number | string; + }; +}; + +type CustomTooltipProps = { + active?: boolean; + payload?: PayloadItem[]; + label?: string; +}; + +const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( +
+
+
+ {payload[0].payload.date} +
+
+ + + + {payload[0].payload.change}} + /> + +
+
+
+ ); + } +}; + +type LineGraphProps = { + style?: CSSProperties; + graphData; + compact: boolean; + domain?: { + y?: [number, number]; + }; +}; + +function LineGraph({ style, graphData, compact, domain }: LineGraphProps) { + const tickFormatter = tick => { + return `${Math.round(tick).toLocaleString()}`; // Formats the tick values as strings with commas + }; + + return ( + + {(width, height, portalHost) => + graphData && ( + +
+ {!compact &&
} + + } + formatter={numberFormatterTooltip} + isAnimationActive={false} + /> + + + + + +
+ + ) + } + + ); +} + +export default LineGraph; diff --git a/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx b/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx index 5a09531c7ac..142ffb75d55 100644 --- a/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx @@ -15,6 +15,7 @@ import { theme } from '../../../style'; import { type CSSProperties } from '../../../style'; import AlignedText from '../../common/AlignedText'; import Container from '../Container'; +import numberFormatterTooltip from '../numberFormatter'; type NetWorthGraphProps = { style?: CSSProperties; @@ -24,14 +25,6 @@ type NetWorthGraphProps = { y?: [number, number]; }; }; -type PotentialNumber = number | string | undefined | null; - -const numberFormatterTooltip = (value: PotentialNumber): number | null => { - if (typeof value === 'number') { - return Math.round(value); - } - return null; // or some default value for other cases -}; function NetWorthGraph({ style, diff --git a/packages/desktop-client/src/components/reports/graphs/SankeyGraph.tsx b/packages/desktop-client/src/components/reports/graphs/SankeyGraph.tsx index 182b0fc7d43..de73a14d6f2 100644 --- a/packages/desktop-client/src/components/reports/graphs/SankeyGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/SankeyGraph.tsx @@ -9,20 +9,13 @@ import { } from 'recharts'; import Container from '../Container'; +import numberFormatterTooltip from '../numberFormatter'; type SankeyProps = { style; data; compact: boolean; }; -type PotentialNumber = number | string | undefined | null; - -const numberFormatterTooltip = (value: PotentialNumber): number | null => { - if (typeof value === 'number') { - return Math.round(value); - } - return null; // or some default value for other cases -}; function SankeyNode({ x, y, width, height, index, payload, containerWidth }) { const isOut = x + width + 6 > containerWidth; diff --git a/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx new file mode 100644 index 00000000000..df19427fb66 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx @@ -0,0 +1,191 @@ +import React from 'react'; + +import { css } from 'glamor'; +import { + BarChart, + Bar, + CartesianGrid, + //Legend, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +import { amountToCurrency } from 'loot-core/src/shared/util'; + +import { theme } from '../../../style'; +import { type CSSProperties } from '../../../style'; +import AlignedText from '../../common/AlignedText'; +import PrivacyFilter from '../../PrivacyFilter'; +import { getColorScale } from '../chart-theme'; +import Container from '../Container'; +import numberFormatterTooltip from '../numberFormatter'; + +type PayloadItem = { + name: string; + value: number; + color: string; + payload: { + name: string; + color: number | string; + }; +}; + +type CustomTooltipProps = { + active?: boolean; + payload?: PayloadItem[]; + label?: string; +}; + +const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (active && payload && payload.length) { + let sumTotals = 0; + return ( +
+
+
+ {label} +
+
+ + {payload + .slice(0) + .reverse() + .map(pay => { + sumTotals += pay.value; + return ( + pay.value !== 0 && ( + + ) + ); + })} + + +
+
+
+ ); + } +}; + +/* Descoped for future PR +type CustomLegendProps = { + active?: boolean; + payload?: PayloadItem[]; + label?: string; +}; + +const CustomLegend = ({ active, payload, label }: CustomLegendProps) => { + const agg = payload.map(leg => { + return { + name: leg.value, + color: leg.color, + }; + }); + + OnChangeLegend(agg.slice(0).reverse()); + + return
; +}; +*/ + +type StackedBarGraphProps = { + style?: CSSProperties; + data; + balanceTypeOp; + compact: boolean; + domain?: { + y?: [number, number]; + }; +}; + +function StackedBarGraph({ + style, + data, + balanceTypeOp, + compact, + domain, +}: StackedBarGraphProps) { + const colorScale = getColorScale('qualitative'); + + const getVal = (obj, key) => { + if (balanceTypeOp === 'totalDebts') { + return -1 * obj[key].amount; + } else { + return obj[key].amount; + } + }; + + return ( + + {(width, height, portalHost) => + data.stackedData && ( + +
+ {!compact &&
} + + { + //} /> + } + } + formatter={numberFormatterTooltip} + isAnimationActive={false} + /> + + + + {data.groupBy + .slice(0) + .reverse() + .map((c, index) => ( + getVal(val, c.name)} + name={c.name} + stackId="a" + fill={colorScale[index % colorScale.length]} + /> + ))} + +
+ + ) + } + + ); +} + +export default StackedBarGraph; diff --git a/packages/desktop-client/src/components/reports/numberFormatter.tsx b/packages/desktop-client/src/components/reports/numberFormatter.tsx new file mode 100644 index 00000000000..cf6af80c967 --- /dev/null +++ b/packages/desktop-client/src/components/reports/numberFormatter.tsx @@ -0,0 +1,10 @@ +type PotentialNumber = number | string | undefined | null; + +const numberFormatterTooltip = (value: PotentialNumber): number | null => { + if (typeof value === 'number') { + return Math.round(value); + } + return null; // or some default value for other cases +}; + +export default numberFormatterTooltip; diff --git a/packages/desktop-client/src/components/reports/CashFlow.js b/packages/desktop-client/src/components/reports/reports/CashFlow.js similarity index 89% rename from packages/desktop-client/src/components/reports/CashFlow.js rename to packages/desktop-client/src/components/reports/reports/CashFlow.js index 5869b7083e0..1b57e1424ca 100644 --- a/packages/desktop-client/src/components/reports/CashFlow.js +++ b/packages/desktop-client/src/components/reports/reports/CashFlow.js @@ -6,20 +6,19 @@ import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { integerToCurrency } from 'loot-core/src/shared/util'; -import useFilters from '../../hooks/useFilters'; -import { theme, styles } from '../../style'; -import AlignedText from '../common/AlignedText'; -import Block from '../common/Block'; -import Paragraph from '../common/Paragraph'; -import Text from '../common/Text'; -import View from '../common/View'; -import PrivacyFilter from '../PrivacyFilter'; - -import Change from './Change'; -import { cashFlowByDate } from './graphs/cash-flow-spreadsheet'; -import CashFlowGraph from './graphs/CashFlowGraph'; -import Header from './Header'; -import useReport from './useReport'; +import useFilters from '../../../hooks/useFilters'; +import { theme, styles } from '../../../style'; +import AlignedText from '../../common/AlignedText'; +import Block from '../../common/Block'; +import Paragraph from '../../common/Paragraph'; +import Text from '../../common/Text'; +import View from '../../common/View'; +import PrivacyFilter from '../../PrivacyFilter'; +import Change from '../Change'; +import CashFlowGraph from '../graphs/CashFlowGraph'; +import Header from '../Header'; +import { cashFlowByDate } from '../spreadsheets/cash-flow-spreadsheet'; +import useReport from '../useReport'; function CashFlow() { const { diff --git a/packages/desktop-client/src/components/reports/reports/CashFlowCard.js b/packages/desktop-client/src/components/reports/reports/CashFlowCard.js new file mode 100644 index 00000000000..1ccf6dfc650 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/CashFlowCard.js @@ -0,0 +1,153 @@ +import React, { useState, useMemo, useCallback } from 'react'; + +import { VictoryBar, VictoryGroup, VictoryVoronoiContainer } from 'victory'; + +import * as monthUtils from 'loot-core/src/shared/months'; +import { integerToCurrency } from 'loot-core/src/shared/util'; + +import { theme, styles } from '../../../style'; +import Block from '../../common/Block'; +import View from '../../common/View'; +import PrivacyFilter from '../../PrivacyFilter'; +import Change from '../Change'; +import { chartTheme } from '../chart-theme'; +import Container from '../Container'; +import DateRange from '../DateRange'; +import { LoadingIndicator } from '../Overview'; +import ReportCard from '../ReportCard'; +import { simpleCashFlow } from '../spreadsheets/cash-flow-spreadsheet'; +import Tooltip from '../Tooltip'; +import useReport from '../useReport'; + +function CashFlowCard() { + const end = monthUtils.currentDay(); + const start = monthUtils.currentMonth() + '-01'; + + const params = useMemo(() => simpleCashFlow(start, end), [start, end]); + const data = useReport('cash_flow_simple', params); + + const [isCardHovered, setIsCardHovered] = useState(false); + const onCardHover = useCallback(() => setIsCardHovered(true)); + const onCardHoverEnd = useCallback(() => setIsCardHovered(false)); + + const { graphData } = data || {}; + const expense = -(graphData?.expense || 0); + const income = graphData?.income || 0; + + return ( + + + + + + Cash Flow + + + + {data && ( + + + + + + )} + + + {data ? ( + + {(width, height, portalHost) => ( + + } + labelComponent={ + (y + 40 > height ? height - 40 : y)} + light={true} + forceActive={true} + style={{ + padding: 0, + }} + /> + } + padding={{ + top: 0, + bottom: 0, + left: 0, + right: 0, + }} + > + + Income + + + {integerToCurrency(income)} + + + + ), + labelPosition: 'left', + }, + ]} + labels={d => d.premadeLabel} + /> + + Expenses + + + {integerToCurrency(expense)} + + + + ), + labelPosition: 'right', + }, + ]} + labels={d => d.premadeLabel} + /> + + )} + + ) : ( + + )} + + + ); +} + +export default CashFlowCard; diff --git a/packages/desktop-client/src/components/reports/CategorySpending.js b/packages/desktop-client/src/components/reports/reports/CategorySpending.js similarity index 90% rename from packages/desktop-client/src/components/reports/CategorySpending.js rename to packages/desktop-client/src/components/reports/reports/CategorySpending.js index eac84d9f932..23649a59e4e 100644 --- a/packages/desktop-client/src/components/reports/CategorySpending.js +++ b/packages/desktop-client/src/components/reports/reports/CategorySpending.js @@ -5,17 +5,16 @@ import * as d from 'date-fns'; import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; -import useCategories from '../../hooks/useCategories'; -import { styles } from '../../style'; -import Select from '../common/Select'; -import View from '../common/View'; - -import CategorySelector from './CategorySelector'; -import categorySpendingSpreadsheet from './graphs/category-spending-spreadsheet'; -import CategorySpendingGraph from './graphs/CategorySpendingGraph'; -import Header from './Header'; -import useReport from './useReport'; -import { fromDateRepr } from './util'; +import useCategories from '../../../hooks/useCategories'; +import { styles } from '../../../style'; +import Select from '../../common/Select'; +import View from '../../common/View'; +import CategorySelector from '../CategorySelector'; +import CategorySpendingGraph from '../graphs/CategorySpendingGraph'; +import Header from '../Header'; +import categorySpendingSpreadsheet from '../spreadsheets/category-spending-spreadsheet'; +import useReport from '../useReport'; +import { fromDateRepr } from '../util'; function CategoryAverage() { const categories = useCategories(); diff --git a/packages/desktop-client/src/components/reports/reports/CategorySpendingCard.js b/packages/desktop-client/src/components/reports/reports/CategorySpendingCard.js new file mode 100644 index 00000000000..1b4742c1820 --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/CategorySpendingCard.js @@ -0,0 +1,63 @@ +import React, { useMemo } from 'react'; + +import * as monthUtils from 'loot-core/src/shared/months'; + +import useCategories from '../../../hooks/useCategories'; +import { styles } from '../../../style'; +import Block from '../../common/Block'; +import View from '../../common/View'; +import DateRange from '../DateRange'; +import CategorySpendingGraph from '../graphs/CategorySpendingGraph'; +import { LoadingIndicator } from '../Overview'; +import ReportCard from '../ReportCard'; +import categorySpendingSpreadsheet from '../spreadsheets/category-spending-spreadsheet'; +import useReport from '../useReport'; + +function CategorySpendingCard() { + const { list: categories = [] } = useCategories(); + + const end = monthUtils.currentDay(); + const start = monthUtils.subMonths(end, 3); + + const params = useMemo(() => { + return categorySpendingSpreadsheet( + start, + end, + 3, + categories.filter(category => !category.is_income && !category.hidden), + ); + }, [start, end, categories]); + + const perCategorySpending = useReport('category_spending', params); + + return ( + + + + + + Spending + + + + + + + {perCategorySpending ? ( + + ) : ( + + )} + + ); +} + +export default CategorySpendingCard; diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.js b/packages/desktop-client/src/components/reports/reports/CustomReport.js new file mode 100644 index 00000000000..8aeb682c95d --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.js @@ -0,0 +1,341 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import * as d from 'date-fns'; + +import { send } from 'loot-core/src/platform/client/fetch'; +import * as monthUtils from 'loot-core/src/shared/months'; +import { amountToCurrency } from 'loot-core/src/shared/util'; + +import useCategories from '../../../hooks/useCategories'; +import useFilters from '../../../hooks/useFilters'; +import { theme, styles } from '../../../style'; +import AlignedText from '../../common/AlignedText'; +import Block from '../../common/Block'; +import Text from '../../common/Text'; +import View from '../../common/View'; +import { AppliedFilters } from '../../filters/FiltersMenu'; +import PrivacyFilter from '../../PrivacyFilter'; +import { ChooseGraph } from '../ChooseGraph'; +import Header from '../Header'; +import { ReportOptions } from '../ReportOptions'; +import { ReportSidebar } from '../ReportSidebar'; +import { ReportLegend, ReportSummary } from '../ReportSummary'; +import { ReportTopbar } from '../ReportTopbar'; +import defaultSpreadsheet from '../spreadsheets/default-spreadsheet'; +import useReport from '../useReport'; +import { fromDateRepr } from '../util'; + +export default function CustomReport() { + const categories = useCategories(); + + let { payees, accounts } = useSelector(state => { + return { + payees: state.queries.payees, + accounts: state.queries.accounts, + }; + }); + + const { + filters, + conditionsOp, + onApply: onApplyFilter, + onDelete: onDeleteFilter, + onUpdate: onUpdateFilter, + onCondOpChange, + } = useFilters(); + + const [selectedCategories, setSelectedCategories] = useState(null); + const [allMonths, setAllMonths] = useState(null); + const [typeDisabled, setTypeDisabled] = useState(['Net']); + const [start, setStart] = useState( + monthUtils.subMonths(monthUtils.currentMonth(), 5), + ); + const [end, setEnd] = useState(monthUtils.currentMonth()); + + const [mode, setMode] = useState('total'); + const [groupBy, setGroupBy] = useState('Category'); + const [balanceType, setBalanceType] = useState('Expense'); + const [empty, setEmpty] = useState(false); + const [hidden, setHidden] = useState(false); + const [uncat, setUncat] = useState(false); + const [dateRange, setDateRange] = useState('6 months'); + + const [graphType, setGraphType] = useState('BarGraph'); + const [viewLegend, setViewLegend] = useState(false); + const [viewSummary, setViewSummary] = useState(false); + const [viewLabels, setViewLabels] = useState(false); + //const [legend, setLegend] = useState([]); + let legend = []; + const dateRangeLine = ReportOptions.dateRange.length - 1; + + const months = monthUtils.rangeInclusive(start, end); + const getGraphData = useMemo(() => { + return defaultSpreadsheet( + start, + end, + groupBy, + ReportOptions.balanceTypeMap.get(balanceType), + categories, + selectedCategories, + payees, + accounts, + filters, + conditionsOp, + hidden, + uncat, + ); + }, [ + start, + end, + groupBy, + balanceType, + categories, + selectedCategories, + payees, + accounts, + filters, + conditionsOp, + hidden, + uncat, + ]); + const data = useReport('default', getGraphData); + + useEffect(() => { + if (selectedCategories === null && categories.list.length !== 0) { + setSelectedCategories(categories.list); + } + }, [categories, selectedCategories]); + + useEffect(() => { + async function run() { + const trans = await send('get-earliest-transaction'); + const currentMonth = monthUtils.currentMonth(); + let earliestMonth = trans + ? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date))) + : currentMonth; + + // Make sure the month selects are at least populates with a + // year's worth of months. We can undo this when we have fancier + // date selects. + const yearAgo = monthUtils.subMonths(monthUtils.currentMonth(), 12); + if (earliestMonth > yearAgo) { + earliestMonth = yearAgo; + } + + const allMonths = monthUtils + .rangeInclusive(earliestMonth, monthUtils.currentMonth()) + .map(month => ({ + name: month, + pretty: monthUtils.format(month, 'MMMM, yyyy'), + })) + .reverse(); + + setAllMonths(allMonths); + } + run(); + }, []); + + let [scrollWidth, setScrollWidth] = useState(0); + + if (!allMonths || !data) { + return null; + } + + const onChangeDates = (start, end) => { + setStart(start); + setEnd(end); + }; + + return ( + +
+ + + + ); +} diff --git a/packages/desktop-client/src/components/reports/reports/CustomReportCard.js b/packages/desktop-client/src/components/reports/reports/CustomReportCard.js new file mode 100644 index 00000000000..1dd43b0b92c --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/CustomReportCard.js @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; + +import * as monthUtils from 'loot-core/src/shared/months'; + +import useCategories from '../../../hooks/useCategories'; +import { styles } from '../../../style'; +import Block from '../../common/Block'; +import View from '../../common/View'; +import DateRange from '../DateRange'; +import BarGraph from '../graphs/BarGraph'; +import { LoadingIndicator } from '../Overview'; +import ReportCard from '../ReportCard'; +import defaultSpreadsheet from '../spreadsheets/default-spreadsheet'; +import useReport from '../useReport'; + +function CustomReportCard() { + const categories = useCategories(); + + const end = monthUtils.currentMonth(); + const start = monthUtils.subMonths(end, 3); + const groupBy = 'Category'; + + const getGraphData = useMemo(() => { + return defaultSpreadsheet(start, end, groupBy, 'totalDebts', categories); + }, [start, end, categories]); + const data = useReport('default', getGraphData); + + return ( + + + + + + Custom Report + + + + + + + {data ? ( + + ) : ( + + )} + + ); +} + +export default CustomReportCard; diff --git a/packages/desktop-client/src/components/reports/NetWorth.js b/packages/desktop-client/src/components/reports/reports/NetWorth.js similarity index 88% rename from packages/desktop-client/src/components/reports/NetWorth.js rename to packages/desktop-client/src/components/reports/reports/NetWorth.js index e03f82d5a65..e3f928b5fb4 100644 --- a/packages/desktop-client/src/components/reports/NetWorth.js +++ b/packages/desktop-client/src/components/reports/reports/NetWorth.js @@ -7,20 +7,19 @@ import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { integerToCurrency } from 'loot-core/src/shared/util'; -import useFilters from '../../hooks/useFilters'; -import { theme, styles } from '../../style'; -import Paragraph from '../common/Paragraph'; -import View from '../common/View'; -import PrivacyFilter from '../PrivacyFilter'; - -import Change from './Change'; -import netWorthSpreadsheet from './graphs/net-worth-spreadsheet'; -import NetWorthGraph from './graphs/NetWorthGraph'; -import Header from './Header'; -import useReport from './useReport'; -import { fromDateRepr } from './util'; - -export default function NetWorth() { +import useFilters from '../../../hooks/useFilters'; +import { theme, styles } from '../../../style'; +import Paragraph from '../../common/Paragraph'; +import View from '../../common/View'; +import PrivacyFilter from '../../PrivacyFilter'; +import Change from '../Change'; +import NetWorthGraph from '../graphs/NetWorthGraph'; +import Header from '../Header'; +import netWorthSpreadsheet from '../spreadsheets/net-worth-spreadsheet'; +import useReport from '../useReport'; +import { fromDateRepr } from '../util'; + +function NetWorth() { let accounts = useSelector(state => state.queries.accounts); const { filters, @@ -153,3 +152,5 @@ export default function NetWorth() { ); } + +export default NetWorth; diff --git a/packages/desktop-client/src/components/reports/reports/NetWorthCard.js b/packages/desktop-client/src/components/reports/reports/NetWorthCard.js new file mode 100644 index 00000000000..060636e0a7a --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/NetWorthCard.js @@ -0,0 +1,87 @@ +import React, { useState, useMemo, useCallback } from 'react'; + +import * as monthUtils from 'loot-core/src/shared/months'; +import { integerToCurrency } from 'loot-core/src/shared/util'; + +import { theme, styles } from '../../../style'; +import Block from '../../common/Block'; +import View from '../../common/View'; +import PrivacyFilter from '../../PrivacyFilter'; +import Change from '../Change'; +import DateRange from '../DateRange'; +import NetWorthGraph from '../graphs/NetWorthGraph'; +import { LoadingIndicator } from '../Overview'; +import ReportCard from '../ReportCard'; +import netWorthSpreadsheet from '../spreadsheets/net-worth-spreadsheet'; +import useReport from '../useReport'; + +function NetWorthCard({ accounts }) { + const end = monthUtils.currentMonth(); + const start = monthUtils.subMonths(end, 5); + const [isCardHovered, setIsCardHovered] = useState(false); + const onCardHover = useCallback(() => setIsCardHovered(true)); + const onCardHoverEnd = useCallback(() => setIsCardHovered(false)); + + const params = useMemo( + () => netWorthSpreadsheet(start, end, accounts), + [start, end, accounts], + ); + const data = useReport('net_worth', params); + + return ( + + + + + + Net Worth + + + + {data && ( + + + + {integerToCurrency(data.netWorth)} + + + + + + + )} + + + {data ? ( + + ) : ( + + )} + + + ); +} + +export default NetWorthCard; diff --git a/packages/desktop-client/src/components/reports/Sankey.js b/packages/desktop-client/src/components/reports/reports/Sankey.js similarity index 88% rename from packages/desktop-client/src/components/reports/Sankey.js rename to packages/desktop-client/src/components/reports/reports/Sankey.js index 849559c23f2..b2b11548681 100644 --- a/packages/desktop-client/src/components/reports/Sankey.js +++ b/packages/desktop-client/src/components/reports/reports/Sankey.js @@ -5,17 +5,16 @@ import * as d from 'date-fns'; import { send } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; -import useCategories from '../../hooks/useCategories'; -import useFilters from '../../hooks/useFilters'; -import { theme, styles } from '../../style'; -import Paragraph from '../common/Paragraph'; -import View from '../common/View'; - -import sankeySpreadsheet from './graphs/sankey-spreadsheet'; -import SankeyGraph from './graphs/SankeyGraph'; -import Header from './Header'; -import useReport from './useReport'; -import { fromDateRepr } from './util'; +import useCategories from '../../../hooks/useCategories'; +import useFilters from '../../../hooks/useFilters'; +import { theme, styles } from '../../../style'; +import Paragraph from '../../common/Paragraph'; +import View from '../../common/View'; +import SankeyGraph from '../graphs/SankeyGraph'; +import Header from '../Header'; +import sankeySpreadsheet from '../spreadsheets/sankey-spreadsheet'; +import useReport from '../useReport'; +import { fromDateRepr } from '../util'; export default function Sankey() { const { grouped: categoryGroups } = useCategories(); diff --git a/packages/desktop-client/src/components/reports/reports/SankeyCard.js b/packages/desktop-client/src/components/reports/reports/SankeyCard.js new file mode 100644 index 00000000000..1732311dbfa --- /dev/null +++ b/packages/desktop-client/src/components/reports/reports/SankeyCard.js @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react'; + +import * as monthUtils from 'loot-core/src/shared/months'; + +import useCategories from '../../../hooks/useCategories'; +import { styles } from '../../../style'; +import Block from '../../common/Block'; +import View from '../../common/View'; +import DateRange from '../DateRange'; +import SankeyGraph from '../graphs/SankeyGraph'; +import { LoadingIndicator } from '../Overview'; +import ReportCard from '../ReportCard'; +import sankeySpreadsheet from '../spreadsheets/sankey-spreadsheet'; +import useReport from '../useReport'; + +function SankeyCard() { + const { grouped: categoryGroups } = useCategories(); + const end = monthUtils.currentMonth(); + const start = monthUtils.subMonths(end, 5); + + const params = useMemo( + () => sankeySpreadsheet(start, end, categoryGroups), + [start, end, categoryGroups], + ); + const data = useReport('sankey', params); + + return ( + + + + + Sankey + + + + + + {data ? ( + + ) : ( + + )} + + + ); +} + +export default SankeyCard; diff --git a/packages/desktop-client/src/components/reports/graphs/cash-flow-spreadsheet.tsx b/packages/desktop-client/src/components/reports/spreadsheets/cash-flow-spreadsheet.tsx similarity index 100% rename from packages/desktop-client/src/components/reports/graphs/cash-flow-spreadsheet.tsx rename to packages/desktop-client/src/components/reports/spreadsheets/cash-flow-spreadsheet.tsx diff --git a/packages/desktop-client/src/components/reports/graphs/category-spending-spreadsheet.tsx b/packages/desktop-client/src/components/reports/spreadsheets/category-spending-spreadsheet.tsx similarity index 100% rename from packages/desktop-client/src/components/reports/graphs/category-spending-spreadsheet.tsx rename to packages/desktop-client/src/components/reports/spreadsheets/category-spending-spreadsheet.tsx diff --git a/packages/desktop-client/src/components/reports/spreadsheets/default-spreadsheet.tsx b/packages/desktop-client/src/components/reports/spreadsheets/default-spreadsheet.tsx new file mode 100644 index 00000000000..a09d95af7d4 --- /dev/null +++ b/packages/desktop-client/src/components/reports/spreadsheets/default-spreadsheet.tsx @@ -0,0 +1,490 @@ +import * as d from 'date-fns'; + +import q, { runQuery } from 'loot-core/src/client/query-helpers'; +import { send } from 'loot-core/src/platform/client/fetch'; +import * as monthUtils from 'loot-core/src/shared/months'; +import { integerToAmount, amountToInteger } from 'loot-core/src/shared/util'; + +import { index } from '../util'; + +export default function createSpreadsheet( + start, + end, + groupBy, + typeItem, + categories, + selectedCategories, + payees, + accounts, + conditions = [], + conditionsOp, + hidden, + uncat, +) { + let uncatCat = { + name: 'Uncategorized', + id: null, + uncat_id: '1', + hidden: 0, + offBudget: false, + }; + let uncatTransfer = { + name: 'Transfers', + id: null, + uncat_id: '2', + hidden: 0, + transfer: false, + }; + let uncatOff = { + name: 'OffBudget', + id: null, + uncat_id: '3', + hidden: 0, + offBudget: true, + }; + + let uncatGroup = { + name: 'Uncategorized', + id: null, + hidden: 0, + categories: [uncatCat, uncatTransfer, uncatOff], + }; + let catList = uncat + ? [...categories.list, uncatCat, uncatTransfer, uncatOff] + : categories.list; + let catGroup = uncat + ? [...categories.grouped, uncatGroup] + : categories.grouped; + + let categoryFilter = (catList || []).filter( + category => + !category.hidden && + selectedCategories && + selectedCategories.some( + selectedCategory => selectedCategory.id === category.id, + ), + ); + + let groupByList; + let groupByLabel; + switch (groupBy) { + case 'Category': + groupByList = catList; + groupByLabel = 'category'; + break; + case 'Group': + groupByList = catList; + groupByLabel = 'category'; + break; + case 'Payee': + groupByList = payees; + groupByLabel = 'payee'; + break; + case 'Account': + groupByList = accounts; + groupByLabel = 'account'; + break; + case 'Month': + groupByList = catList; + groupByLabel = 'category'; + break; + case 'Year': + groupByList = catList; + groupByLabel = 'category'; + break; + default: + } + + return async (spreadsheet, setData) => { + if (groupByList.length === 0) { + return null; + } + + let { filters } = await send('make-filters-from-conditions', { + conditions: conditions.filter(cond => !cond.customName), + }); + const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and'; + + function makeQuery(splt, name) { + let query = q('transactions') + .filter( + //Show Offbudget and hidden categories + !hidden && { + $and: [ + { + 'account.offbudget': false, + $or: [ + { + 'category.hidden': false, + category: null, + }, + ], + }, + ], + $or: [ + { + 'payee.transfer_acct.offbudget': true, + 'payee.transfer_acct': null, + }, + ], + }, + ) + //Apply Category_Selector + .filter( + selectedCategories && { + $or: [ + { + category: null, + $or: categoryFilter.map(category => ({ + category: category.id, + })), + }, + ], + }, + ) + //Calculate uncategorized transactions when box checked + .filter( + splt.uncat_id === '2' + ? { + 'payee.transfer_acct.closed': false, + } + : { + 'payee.transfer_acct': null, + 'account.offbudget': splt.offBudget ? splt.offBudget : false, + }, + ) + //Apply filters and split by "Group By" + .filter({ + [conditionsOpKey]: [...filters], + [groupByLabel]: splt.id, + }) + //Apply month range filters + .filter({ + $and: [ + { date: { $transform: '$month', $gte: start } }, + { date: { $transform: '$month', $lte: end } }, + ], + }) + //Show assets or debts + .filter( + name === 'assets' ? { amount: { $gt: 0 } } : { amount: { $lt: 0 } }, + ); + + return query + .groupBy({ $month: '$date' }) + .select([ + { date: { $month: '$date' } }, + { [name]: { $sum: '$amount' } }, + ]); + } + + const graphData = await Promise.all( + groupByList.map(async splt => { + let [starting, assets, debts] = await Promise.all([ + runQuery( + q('transactions') + .filter( + !hidden && { + $and: [ + { + 'account.offbudget': false, + $or: [ + { + 'category.hidden': false, + category: null, + }, + ], + }, + ], + $or: [ + { + 'payee.transfer_acct.offbudget': true, + 'payee.transfer_acct': null, + }, + ], + }, + ) + .filter( + splt.uncat_id === '2' + ? { + 'payee.transfer_acct.closed': false, + } + : { + 'payee.transfer_acct': null, + 'account.offbudget': splt.offBudget + ? splt.offBudget + : false, + }, + ) + .filter( + selectedCategories && { + $or: categoryFilter.map(category => ({ + category: category.id, + })), + }, + ) + .filter({ + [conditionsOpKey]: [...filters], + [groupByLabel]: splt.id, + }) + .filter({ + $and: [{ date: { $lt: start + '-01' } }], + }) + .calculate({ $sum: '$amount' }), + ).then(({ data }) => data), + + runQuery(makeQuery(splt, 'assets')).then(({ data }) => data), + runQuery(makeQuery(splt, 'debts')).then(({ data }) => data), + ]); + + return { + id: splt.id, + uncat_id: splt.uncat_id, + name: splt.name, + starting, + hidden: splt.hidden, + assets: index(assets, 'date'), + debts: index(debts, 'date'), + }; + }), + ); + + const months = monthUtils.rangeInclusive(start, end); + const calcData = graphData.map(graph => { + let graphStarting = 0; + const mon = months.map(month => { + let graphAssets = 0; + let graphDebts = 0; + if (graph.assets[month] || graph.debts[month]) { + if (graph.assets[month]) { + graphAssets += graph.assets[month].assets; + } + if (graph.debts[month]) { + graphDebts += graph.debts[month].debts; + } + } + + graphStarting += graph.starting; + return { + date: month, + assets: graphAssets, + debts: graphDebts, + }; + }); + + return { + id: graph.id, + uncat_id: graph.uncat_id, + name: graph.name, + starting: graphStarting, + hidden: graph.hidden, + balances: index(mon, 'date'), + }; + }); + + const categoryGroupCalcData = catGroup.map(group => { + if (hidden || group.hidden === 0) { + let groupedStarting = 0; + const mon = months.map(month => { + let groupedAssets = 0; + let groupedDebts = 0; + graphData.map(graph => { + if (graph.assets[month] || graph.debts[month]) { + if (group.categories.map(v => v.id).includes(graph.id)) { + if (graph.assets[month]) { + groupedAssets += graph.assets[month].assets; + } + if (graph.debts[month]) { + groupedDebts += graph.debts[month].debts; + } + } + } + + groupedStarting += graph.starting; + return null; + }); + return { + date: month, + assets: groupedAssets, + debts: groupedDebts, + }; + }); + + return { + id: group.id, + name: group.name, + starting: groupedStarting, + hidden: group.hidden, + balances: index(mon, 'date'), + }; + } else { + return null; + } + }); + + const groupByData = groupBy === 'Group' ? categoryGroupCalcData : calcData; + + const data = groupByData.map(graph => { + const calc = recalculate(graph, start, end); + return { ...calc }; + }); + + const categoryGroupData = catGroup.map(group => { + const catData = group.categories.map(graph => { + let catMatch = null; + calcData.map(cat => { + if ( + cat.id === null + ? cat.uncat_id === graph.uncat_id + : cat.id === graph.id + ) { + catMatch = cat; + } + return null; + }); + const calcCat = catMatch && recalculate(catMatch, start, end); + return { ...calcCat }; + }); + let groupMatch = null; + categoryGroupCalcData.map(split => { + if (split.id === group.id) { + groupMatch = split; + } + return null; + }); + const calcGroup = groupMatch && recalculate(groupMatch, start, end); + return { + ...calcGroup, + categories: catData, + }; + }); + + let totalAssets = 0; + let totalDebts = 0; + let totalTotals = 0; + + const monthData = months.map(month => { + let perMonthAssets = 0; + let perMonthDebts = 0; + let perMonthTotals = 0; + graphData.map(graph => { + if (graph.assets[month] || graph.debts[month]) { + if (graph.assets[month]) { + perMonthAssets += graph.assets[month].assets; + } + if (graph.debts[month]) { + perMonthDebts += graph.debts[month].debts; + } + perMonthTotals = perMonthAssets + perMonthDebts; + } + return null; + }); + totalAssets += perMonthAssets; + totalDebts += perMonthDebts; + totalTotals += perMonthTotals; + + return { + // eslint-disable-next-line rulesdir/typography + date: d.format(d.parseISO(`${month}-01`), "MMM ''yy"), + totalDebts: integerToAmount(perMonthDebts), + totalAssets: integerToAmount(perMonthAssets), + totalTotals: integerToAmount(perMonthTotals), + }; + }); + + const stackedData = months.map(month => { + let perMonthAmounts = 0; + const stacked = data.map(graph => { + let stackAmounts = 0; + if (graph.indexedMonthData[month]) { + perMonthAmounts += graph.indexedMonthData[month][typeItem]; + stackAmounts += graph.indexedMonthData[month][typeItem]; + } + return { + name: graph.name, + id: graph.id, + amount: stackAmounts, + }; + }); + + const indexedStack = index(stacked, 'name'); + return { + // eslint-disable-next-line rulesdir/typography + date: d.format(d.parseISO(`${month}-01`), "MMM ''yy"), + ...indexedStack, + totalTotals: perMonthAmounts, + }; + }); + + setData({ + stackedData: stackedData, + groupBy: groupBy === 'Group' ? catGroup : groupByList, + data, + groupData: categoryGroupData, + monthData, + start, + end, + totalDebts: integerToAmount(totalDebts), + totalAssets: integerToAmount(totalAssets), + totalTotals: integerToAmount(totalTotals), + }); + }; +} + +function recalculate(item, start, end) { + const months = monthUtils.rangeInclusive(start, end); + + let totalDebts = 0; + let totalAssets = 0; + let totalTotals = 0; + let exists = false; + + const monthData = months.reduce((arr, month) => { + let debts = 0; + let assets = 0; + let total = 0; + const last = arr.length === 0 ? null : arr[arr.length - 1]; + + if (item.balances[month]) { + exists = true; + if (item.balances[month].debts) { + debts += item.balances[month].debts; + totalDebts += item.balances[month].debts; + } + if (item.balances[month].assets) { + assets += item.balances[month].assets; + totalAssets += item.balances[month].assets; + } + total = assets + debts; + totalTotals = totalAssets + totalDebts; + } + + const dateParse = d.parseISO(`${month}-01`); + const change = last ? total - amountToInteger(last.totalTotals) : 0; + + arr.push({ + dateParse, + totalTotals: integerToAmount(total), + totalAssets: integerToAmount(assets), + totalDebts: integerToAmount(debts), + totalChange: integerToAmount(change), + // eslint-disable-next-line rulesdir/typography + date: d.format(dateParse, "MMM ''yy"), + dateLookup: month, + }); + + return arr; + }, []); + + const indexedMonthData = exists ? index(monthData, 'dateLookup') : monthData; + + return { + indexedMonthData: indexedMonthData, + monthData: monthData, + totalAssets: integerToAmount(totalAssets), + totalDebts: integerToAmount(totalDebts), + totalTotals: integerToAmount(totalTotals), + id: item.id, + name: item.name, + }; +} diff --git a/packages/desktop-client/src/components/reports/graphs/net-worth-spreadsheet.tsx b/packages/desktop-client/src/components/reports/spreadsheets/net-worth-spreadsheet.tsx similarity index 100% rename from packages/desktop-client/src/components/reports/graphs/net-worth-spreadsheet.tsx rename to packages/desktop-client/src/components/reports/spreadsheets/net-worth-spreadsheet.tsx diff --git a/packages/desktop-client/src/components/reports/graphs/sankey-spreadsheet.tsx b/packages/desktop-client/src/components/reports/spreadsheets/sankey-spreadsheet.tsx similarity index 100% rename from packages/desktop-client/src/components/reports/graphs/sankey-spreadsheet.tsx rename to packages/desktop-client/src/components/reports/spreadsheets/sankey-spreadsheet.tsx diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index e8e62cbf7c0..37ed8417705 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -101,7 +101,7 @@ export default function ExperimentalFeatures() { Category spending report - + Custom reports Sankey report diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts index 4a48e585781..0bd5aa83c71 100644 --- a/packages/desktop-client/src/hooks/useFeatureFlag.ts +++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts @@ -8,6 +8,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record = { reportBudget: false, goalTemplatesEnabled: false, themes: false, + customReports: false, experimentalOfxParser: true, }; diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index 65a04d46063..187897d0206 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -6,6 +6,7 @@ export type FeatureFlag = | 'reportBudget' | 'goalTemplatesEnabled' | 'themes' + | 'customReports' | 'experimentalOfxParser'; export type LocalPrefs = Partial< diff --git a/upcoming-release-notes/1791.md b/upcoming-release-notes/1791.md new file mode 100644 index 00000000000..be7d15a4606 --- /dev/null +++ b/upcoming-release-notes/1791.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [carkom] +--- + +Create and implement a customizable charts page. Currently hidden under feature flag (experimental). To include ability to save charts and show tiles on Overview page (future PR)