Custom Reports - split out hidden categories from offbudget (#2302)

* Add Toggles

* budget table

* testing

* updates

* updates

* fixes

* updates

* fix Menu

* lint fixes

* fix keybindings

* revert budget menu changes

* notes

* remove default exports

* fixes

* disabled fix

* add style option

* lint fix

* remove css

* lint fixes

* color updates

* merge menu with togglemenu

* host

* menu fixes

* fix regression

* remove host

* adjustments

* work

* fix hidden filters

* merge fixes

* adjustments

* updates

* fix uncat table values

* fixes

* notes

* title change

* Adjust showHide selector
This commit is contained in:
Neil
2024-02-02 12:09:27 -08:00
committed by GitHub
parent d8639a2a71
commit c8d326d24b
14 changed files with 250 additions and 142 deletions

View File

@@ -22,36 +22,41 @@ import { GraphButton } from './GraphButton';
type CategorySelectorProps = {
categoryGroups: Array<CategoryGroupEntity>;
categories: Array<CategoryEntity>;
selectedCategories: CategoryListProps['items'];
setSelectedCategories: (selectedCategories: CategoryEntity[]) => null;
showHiddenCategories?: boolean;
};
export function CategorySelector({
categoryGroups,
categories,
selectedCategories,
setSelectedCategories,
showHiddenCategories = true,
}: CategorySelectorProps) {
const [uncheckedHidden, setUncheckedHidden] = useState(false);
const filteredGroup = (categoryGroup: CategoryGroupEntity) => {
return categoryGroup.categories.filter(f => {
return showHiddenCategories || !f.hidden ? true : false;
});
};
const selectAll: CategoryEntity[] = [];
categoryGroups.map(categoryGroup =>
filteredGroup(categoryGroup).map(category => selectAll.push(category)),
);
const selectedCategoryMap = useMemo(
() => selectedCategories.map(selected => selected.id),
[selectedCategories],
);
const allCategoriesSelected = categories.every(category =>
const allCategoriesSelected = selectAll.every(category =>
selectedCategoryMap.includes(category.id),
);
const allCategoriesUnselected = !categories.some(category =>
const allCategoriesUnselected = !selectAll.some(category =>
selectedCategoryMap.includes(category.id),
);
const selectAll: CategoryEntity[] = [];
categoryGroups.map(categoryGroup =>
categoryGroup.categories.map(category => selectAll.push(category)),
);
return (
<View>
<View
@@ -126,13 +131,14 @@ export function CategorySelector({
>
{categoryGroups &&
categoryGroups.map(categoryGroup => {
const allCategoriesInGroupSelected = categoryGroup.categories.every(
category =>
selectedCategories.some(
selectedCategory => selectedCategory.id === category.id,
),
const allCategoriesInGroupSelected = filteredGroup(
categoryGroup,
).every(category =>
selectedCategories.some(
selectedCategory => selectedCategory.id === category.id,
),
);
const noCategorySelected = categoryGroup.categories.every(
const noCategorySelected = filteredGroup(categoryGroup).every(
category =>
!selectedCategories.some(
selectedCategory => selectedCategory.id === category.id,
@@ -155,7 +161,7 @@ export function CategorySelector({
const selectedCategoriesExcludingGroupCategories =
selectedCategories.filter(
selectedCategory =>
!categoryGroup.categories.some(
!filteredGroup(categoryGroup).some(
groupCategory =>
groupCategory.id === selectedCategory.id,
),
@@ -167,7 +173,7 @@ export function CategorySelector({
} else {
setSelectedCategories(
selectedCategoriesExcludingGroupCategories.concat(
categoryGroup.categories,
filteredGroup(categoryGroup),
),
);
}
@@ -189,7 +195,7 @@ export function CategorySelector({
paddingLeft: 10,
}}
>
{categoryGroup.categories.map(category => {
{filteredGroup(categoryGroup).map(category => {
const isChecked = selectedCategories.some(
selectedCategory => selectedCategory.id === category.id,
);

View File

@@ -16,7 +16,8 @@ export const defaultState: CustomReportEntity = {
groupBy: 'Category',
balanceType: 'Payment',
showEmpty: false,
showOffBudgetHidden: false,
showOffBudget: false,
showHiddenCategories: false,
showUncategorized: false,
graphType: 'BarGraph',
startDate,
@@ -77,7 +78,9 @@ const intervalOptions = [
export type QueryDataEntity = {
date: string;
category: string;
categoryHidden: boolean;
categoryGroup: string;
categoryGroupHidden: boolean;
account: string;
accountOffBudget: boolean;
payee: string;
@@ -85,10 +88,7 @@ export type QueryDataEntity = {
amount: number;
};
export type UncategorizedEntity = Pick<
CategoryEntity,
'name' | 'id' | 'hidden'
> & {
export type UncategorizedEntity = Pick<CategoryEntity, 'name' | 'hidden'> & {
/*
When looking at uncategorized and hidden transactions we
need a way to group them. To do this we give them a unique
@@ -104,7 +104,6 @@ export type UncategorizedEntity = Pick<
const uncategorizedCategory: UncategorizedEntity = {
name: 'Uncategorized',
id: undefined,
uncategorized_id: '1',
hidden: false,
is_off_budget: false,
@@ -113,7 +112,6 @@ const uncategorizedCategory: UncategorizedEntity = {
};
const transferCategory: UncategorizedEntity = {
name: 'Transfers',
id: undefined,
uncategorized_id: '2',
hidden: false,
is_off_budget: false,
@@ -122,7 +120,6 @@ const transferCategory: UncategorizedEntity = {
};
const offBudgetCategory: UncategorizedEntity = {
name: 'Off Budget',
id: undefined,
uncategorized_id: '3',
hidden: false,
is_off_budget: true,
@@ -144,26 +141,19 @@ const uncategorizedGroup: UncategorizedGroupEntity = {
categories: [uncategorizedCategory, transferCategory, offBudgetCategory],
};
export const categoryLists = (
showOffBudgetHidden: boolean,
showUncategorized: boolean,
categories: { list: CategoryEntity[]; grouped: CategoryGroupEntity[] },
) => {
const categoryList = showUncategorized
? [
...categories.list.filter(f => showOffBudgetHidden || !f.hidden),
uncategorizedCategory,
transferCategory,
offBudgetCategory,
]
: categories.list;
const categoryGroup = showUncategorized
? [
...categories.grouped.filter(f => showOffBudgetHidden || !f.hidden),
uncategorizedGroup,
]
: categories.grouped;
return [categoryList, categoryGroup] as const;
export const categoryLists = (categories: {
list: CategoryEntity[];
grouped: CategoryGroupEntity[];
}) => {
const categoryList = [
...categories.list,
uncategorizedCategory,
offBudgetCategory,
transferCategory,
];
const categoryGroup = [...categories.grouped, uncategorizedGroup];
return [categoryList, categoryGroup.filter(group => group !== null)] as const;
};
export const groupBySelections = (

View File

@@ -35,7 +35,8 @@ export function ReportSidebar({
setMode,
setIsDateStatic,
setShowEmpty,
setShowOffBudgetHidden,
setShowOffBudget,
setShowHiddenCategories,
setShowUncategorized,
setSelectedCategories,
onChangeDates,
@@ -266,10 +267,12 @@ export function ReportSidebar({
<Menu
onMenuSelect={type => {
if (type === 'show-hidden-categories') {
setShowOffBudgetHidden(
!customReportItems.showOffBudgetHidden,
setShowHiddenCategories(
!customReportItems.showHiddenCategories,
);
} else if (type === 'show-empty-rows') {
} else if (type === 'show-off-budget') {
setShowOffBudget(!customReportItems.showOffBudget);
} else if (type === 'show-empty-items') {
setShowEmpty(!customReportItems.showEmpty);
} else if (type === 'show-uncategorized') {
setShowUncategorized(
@@ -279,20 +282,26 @@ export function ReportSidebar({
}}
items={[
{
name: 'show-empty-rows',
text: 'Show Empty Rows',
name: 'show-hidden-categories',
text: 'Show hidden categories',
tooltip: 'Show hidden categories',
toggle: customReportItems.showHiddenCategories,
},
{
name: 'show-empty-items',
text: 'Show empty rows',
tooltip: 'Show rows that are zero or blank',
toggle: customReportItems.showEmpty,
},
{
name: 'show-hidden-categories',
text: 'Show Off Budget',
tooltip: 'Show off budget accounts and hidden categories',
toggle: customReportItems.showOffBudgetHidden,
name: 'show-off-budget',
text: 'Show off budget',
tooltip: 'Show off budget accounts',
toggle: customReportItems.showOffBudget,
},
{
name: 'show-uncategorized',
text: 'Show Uncategorized',
text: 'Show uncategorized',
tooltip: 'Show uncategorized transactions',
toggle: customReportItems.showUncategorized,
},
@@ -440,10 +449,14 @@ export function ReportSidebar({
}}
>
<CategorySelector
categoryGroups={categories.grouped}
categories={categories.list}
categoryGroups={categories.grouped.filter(f => {
return customReportItems.showHiddenCategories || !f.hidden
? true
: false;
})}
selectedCategories={customReportItems.selectedCategories}
setSelectedCategories={setSelectedCategories}
showHiddenCategories={customReportItems.showHiddenCategories}
/>
</View>
)}

View File

@@ -144,7 +144,6 @@ export function CategorySpending() {
>
<View style={{ width: 200 }}>
<CategorySelector
categories={categories.list}
categoryGroups={categories.grouped.filter(
categoryGroup => !categoryGroup.is_income,
)}

View File

@@ -69,8 +69,9 @@ export function CustomReport() {
const [groupBy, setGroupBy] = useState(loadReport.groupBy);
const [balanceType, setBalanceType] = useState(loadReport.balanceType);
const [showEmpty, setShowEmpty] = useState(loadReport.showEmpty);
const [showOffBudgetHidden, setShowOffBudgetHidden] = useState(
loadReport.showOffBudgetHidden,
const [showOffBudget, setShowOffBudget] = useState(loadReport.showOffBudget);
const [showHiddenCategories, setShowHiddenCategories] = useState(
loadReport.showHiddenCategories,
);
const [showUncategorized, setShowUncategorized] = useState(
loadReport.showUncategorized,
@@ -131,7 +132,8 @@ export function CustomReport() {
conditions: filters,
conditionsOp,
showEmpty,
showOffBudgetHidden,
showOffBudget,
showHiddenCategories,
showUncategorized,
balanceTypeOp,
});
@@ -147,7 +149,8 @@ export function CustomReport() {
filters,
conditionsOp,
showEmpty,
showOffBudgetHidden,
showOffBudget,
showHiddenCategories,
showUncategorized,
graphType,
]);
@@ -162,7 +165,8 @@ export function CustomReport() {
conditions: filters,
conditionsOp,
showEmpty,
showOffBudgetHidden,
showOffBudget,
showHiddenCategories,
showUncategorized,
groupBy,
balanceTypeOp,
@@ -183,7 +187,8 @@ export function CustomReport() {
filters,
conditionsOp,
showEmpty,
showOffBudgetHidden,
showOffBudget,
showHiddenCategories,
showUncategorized,
graphType,
]);
@@ -198,7 +203,8 @@ export function CustomReport() {
groupBy,
balanceType,
showEmpty,
showOffBudgetHidden,
showOffBudget,
showHiddenCategories,
showUncategorized,
graphType,
startDate,
@@ -259,7 +265,8 @@ export function CustomReport() {
setMode={setMode}
setIsDateStatic={setIsDateStatic}
setShowEmpty={setShowEmpty}
setShowOffBudgetHidden={setShowOffBudgetHidden}
setShowOffBudget={setShowOffBudget}
setShowHiddenCategories={setShowHiddenCategories}
setShowUncategorized={setShowUncategorized}
setSelectedCategories={setSelectedCategories}
onChangeDates={onChangeDates}

View File

@@ -27,6 +27,10 @@ export function CustomReportCard(reports) {
groupBy,
balanceTypeOp: 'totalDebts',
categories,
showEmpty: false,
showOffBudget: false,
showHiddenCategories: false,
showUncategorized: false,
});
}, [startDate, endDate, categories]);
const data = useReport('default', getGraphData);

View File

@@ -16,6 +16,7 @@ import {
import { categoryLists, groupBySelections } from '../ReportOptions';
import { calculateLegend } from './calculateLegend';
import { filterEmptyRows } from './filterEmptyRows';
import { filterHiddenItems } from './filterHiddenItems';
import { makeQuery } from './makeQuery';
import { recalculate } from './recalculate';
@@ -28,14 +29,15 @@ export type createCustomSpreadsheetProps = {
conditions: RuleConditionEntity[];
conditionsOp: string;
showEmpty: boolean;
showOffBudgetHidden: boolean;
showOffBudget: boolean;
showHiddenCategories: boolean;
showUncategorized: boolean;
groupBy?: string;
balanceTypeOp?: string;
payees?: PayeeEntity[];
accounts?: AccountEntity[];
setDataCheck?: (value: boolean) => void;
graphType: string;
graphType?: string;
};
export function createCustomSpreadsheet({
@@ -46,7 +48,8 @@ export function createCustomSpreadsheet({
conditions = [],
conditionsOp,
showEmpty,
showOffBudgetHidden,
showOffBudget,
showHiddenCategories,
showUncategorized,
groupBy,
balanceTypeOp,
@@ -55,15 +58,10 @@ export function createCustomSpreadsheet({
setDataCheck,
graphType,
}: createCustomSpreadsheetProps) {
const [categoryList, categoryGroup] = categoryLists(
showOffBudgetHidden,
showUncategorized,
categories,
);
const [categoryList, categoryGroup] = categoryLists(categories);
const categoryFilter = (categoryList || []).filter(
const categoryFilter = (categories.list || []).filter(
category =>
!category.hidden &&
selectedCategories &&
selectedCategories.some(
selectedCategory => selectedCategory.id === category.id,
@@ -94,7 +92,6 @@ export function createCustomSpreadsheet({
'assets',
startDate,
endDate,
showOffBudgetHidden,
selectedCategories,
categoryFilter,
conditionsOpKey,
@@ -106,7 +103,6 @@ export function createCustomSpreadsheet({
'debts',
startDate,
endDate,
showOffBudgetHidden,
selectedCategories,
categoryFilter,
conditionsOpKey,
@@ -128,15 +124,31 @@ export function createCustomSpreadsheet({
groupByList.map(item => {
let stackAmounts = 0;
const monthAssets = filterHiddenItems(item, assets)
const monthAssets = filterHiddenItems(
item,
assets,
showOffBudget,
showHiddenCategories,
showUncategorized,
)
.filter(
asset => asset.date === month && asset[groupByLabel] === item.id,
asset =>
asset.date === month && asset[groupByLabel] === (item.id ?? null),
)
.reduce((a, v) => (a = a + v.amount), 0);
perMonthAssets += monthAssets;
const monthDebts = filterHiddenItems(item, debts)
.filter(debt => debt.date === month && debt[groupByLabel] === item.id)
const monthDebts = filterHiddenItems(
item,
debts,
showOffBudget,
showHiddenCategories,
showUncategorized,
)
.filter(
debt =>
debt.date === month && debt[groupByLabel] === (item.id ?? null),
)
.reduce((a, v) => (a = a + v.amount), 0);
perMonthDebts += monthDebts;
@@ -168,11 +180,20 @@ export function createCustomSpreadsheet({
}, []);
const calcData = groupByList.map(item => {
const calc = recalculate({ item, months, assets, debts, groupByLabel });
const calc = recalculate({
item,
months,
assets,
debts,
groupByLabel,
showOffBudget,
showHiddenCategories,
showUncategorized,
});
return { ...calc };
});
const calcDataFiltered = calcData.filter(i =>
!showEmpty ? i[balanceTypeOp] !== 0 : true,
filterEmptyRows(showEmpty, i, balanceTypeOp),
);
const legend = calculateLegend(

View File

@@ -0,0 +1,19 @@
// @ts-strict-ignore
import { type GroupedEntity } from 'loot-core/types/models/reports';
export function filterEmptyRows(
showEmpty: boolean,
data: GroupedEntity,
balanceTypeOp: string,
): boolean {
let showHide;
if (balanceTypeOp === 'totalTotals') {
showHide =
data['totalDebts'] !== 0 ||
data['totalAssets'] !== 0 ||
data['totalTotals'] !== 0;
} else {
showHide = data[balanceTypeOp] !== 0;
}
return !showEmpty ? showHide : true;
}

View File

@@ -6,19 +6,46 @@ import {
export function filterHiddenItems(
item: UncategorizedEntity,
data: QueryDataEntity[],
showOffBudget?: boolean,
showHiddenCategories?: boolean,
showUncategorized?: boolean,
) {
return data.filter(asset => {
const showHide = data
.filter(e =>
!showHiddenCategories
? e.categoryHidden === false && e.categoryGroupHidden === false
: true,
)
.filter(f =>
showOffBudget
? showUncategorized
? //true,true
true
: //true,false
f.category !== null ||
f.accountOffBudget !== false ||
f.transferAccount !== null
: showUncategorized
? //false, true
f.accountOffBudget === false && f.transferAccount === null
: //false false
f.category !== null &&
f.accountOffBudget === false &&
f.transferAccount === null,
);
return showHide.filter(query => {
if (!item.uncategorized_id) {
return true;
}
const isTransfer = item.is_transfer
? asset.transferAccount
: !asset.transferAccount;
const isHidden = item.has_category ? true : !asset.category;
? query.transferAccount
: !query.transferAccount;
const isHidden = item.has_category ? true : !query.category;
const isOffBudget = item.is_off_budget
? asset.accountOffBudget
: !asset.accountOffBudget;
? query.accountOffBudget
: !query.accountOffBudget;
return isTransfer && isHidden && isOffBudget;
});

View File

@@ -3,10 +3,12 @@ import { 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 } from 'loot-core/src/shared/util';
import { type GroupedEntity } from 'loot-core/types/models/reports';
import { categoryLists } from '../ReportOptions';
import { type createCustomSpreadsheetProps } from './custom-spreadsheet';
import { filterEmptyRows } from './filterEmptyRows';
import { filterHiddenItems } from './filterHiddenItems';
import { makeQuery } from './makeQuery';
import { recalculate } from './recalculate';
@@ -19,19 +21,15 @@ export function createGroupedSpreadsheet({
conditions = [],
conditionsOp,
showEmpty,
showOffBudgetHidden,
showOffBudget,
showHiddenCategories,
showUncategorized,
balanceTypeOp,
}: createCustomSpreadsheetProps) {
const [categoryList, categoryGroup] = categoryLists(
showOffBudgetHidden,
showUncategorized,
categories,
);
const [categoryList, categoryGroup] = categoryLists(categories);
const categoryFilter = (categoryList || []).filter(
const categoryFilter = (categories.list || []).filter(
category =>
!category.hidden &&
selectedCategories &&
selectedCategories.some(
selectedCategory => selectedCategory.id === category.id,
@@ -54,7 +52,6 @@ export function createGroupedSpreadsheet({
'assets',
startDate,
endDate,
showOffBudgetHidden,
selectedCategories,
categoryFilter,
conditionsOpKey,
@@ -66,7 +63,6 @@ export function createGroupedSpreadsheet({
'debts',
startDate,
endDate,
showOffBudgetHidden,
selectedCategories,
categoryFilter,
conditionsOpKey,
@@ -77,7 +73,7 @@ export function createGroupedSpreadsheet({
const months = monthUtils.rangeInclusive(startDate, endDate);
const groupedData = categoryGroup.map(
const groupedData: GroupedEntity[] = categoryGroup.map(
group => {
let totalAssets = 0;
let totalDebts = 0;
@@ -87,16 +83,30 @@ export function createGroupedSpreadsheet({
let groupedDebts = 0;
group.categories.forEach(item => {
const monthAssets = filterHiddenItems(item, assets)
const monthAssets = filterHiddenItems(
item,
assets,
showOffBudget,
showHiddenCategories,
showUncategorized,
)
.filter(
asset => asset.date === month && asset.category === item.id,
asset =>
asset.date === month && asset.category === (item.id ?? null),
)
.reduce((a, v) => (a = a + v.amount), 0);
groupedAssets += monthAssets;
const monthDebts = filterHiddenItems(item, debts)
const monthDebts = filterHiddenItems(
item,
debts,
showOffBudget,
showHiddenCategories,
showUncategorized,
)
.filter(
debts => debts.date === month && debts.category === item.id,
debts =>
debts.date === month && debts.category === (item.id ?? null),
)
.reduce((a, v) => (a = a + v.amount), 0);
groupedDebts += monthDebts;
@@ -122,6 +132,9 @@ export function createGroupedSpreadsheet({
assets,
debts,
groupByLabel: 'category',
showOffBudget,
showHiddenCategories,
showUncategorized,
});
return { ...calc };
});
@@ -134,14 +147,14 @@ export function createGroupedSpreadsheet({
totalTotals: integerToAmount(totalAssets + totalDebts),
monthData,
categories: stackedCategories.filter(i =>
!showEmpty ? i[balanceTypeOp] !== 0 : true,
filterEmptyRows(showEmpty, i, balanceTypeOp),
),
};
},
[startDate, endDate],
);
setData(
groupedData.filter(i => (!showEmpty ? i[balanceTypeOp] !== 0 : true)),
groupedData.filter(i => filterEmptyRows(showEmpty, i, balanceTypeOp)),
);
};
}

View File

@@ -5,35 +5,12 @@ export function makeQuery(
name: string,
startDate: string,
endDate: string,
showOffBudgetHidden: boolean,
selectedCategories: CategoryEntity[],
categoryFilter: CategoryEntity[],
conditionsOpKey: string,
filters: unknown[],
) {
const query = q('transactions')
.filter(
//Show Offbudget and hidden categories
!showOffBudgetHidden && {
$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 && {
@@ -74,7 +51,9 @@ export function makeQuery(
.select([
{ date: { $month: '$date' } },
{ category: { $id: '$category.id' } },
{ categoryHidden: { $id: '$category.hidden' } },
{ categoryGroup: { $id: '$category.group.id' } },
{ categoryGroupHidden: { $id: '$category.group.hidden' } },
{ account: { $id: '$account.id' } },
{ accountOffBudget: { $id: '$account.offbudget' } },
{ payee: { $id: '$payee.id' } },

View File

@@ -13,6 +13,9 @@ type recalculateProps = {
assets: QueryDataEntity[];
debts: QueryDataEntity[];
groupByLabel: string;
showOffBudget?: boolean;
showHiddenCategories?: boolean;
showUncategorized?: boolean;
};
export function recalculate({
@@ -21,19 +24,39 @@ export function recalculate({
assets,
debts,
groupByLabel,
showOffBudget,
showHiddenCategories,
showUncategorized,
}: recalculateProps) {
let totalAssets = 0;
let totalDebts = 0;
const monthData = months.reduce((arr, month) => {
const last = arr.length === 0 ? null : arr[arr.length - 1];
const monthAssets = filterHiddenItems(item, assets)
.filter(asset => asset.date === month && asset[groupByLabel] === item.id)
const monthAssets = filterHiddenItems(
item,
assets,
showOffBudget,
showHiddenCategories,
showUncategorized,
)
.filter(
asset =>
asset.date === month && asset[groupByLabel] === (item.id ?? null),
)
.reduce((a, v) => (a = a + v.amount), 0);
totalAssets += monthAssets;
const monthDebts = filterHiddenItems(item, debts)
.filter(debt => debt.date === month && debt[groupByLabel] === item.id)
const monthDebts = filterHiddenItems(
item,
debts,
showOffBudget,
showHiddenCategories,
showUncategorized,
)
.filter(
debt => debt.date === month && debt[groupByLabel] === (item.id ?? null),
)
.reduce((a, v) => (a = a + v.amount), 0);
totalDebts += monthDebts;

View File

@@ -6,7 +6,8 @@ export interface CustomReportEntity {
groupBy: string;
balanceType: string;
showEmpty: boolean;
showOffBudgetHidden: boolean;
showOffBudget: boolean;
showHiddenCategories: boolean;
showUncategorized: boolean;
graphType: string;
selectedCategories;
@@ -21,12 +22,12 @@ export interface CustomReportEntity {
}
export interface GroupedEntity {
data: DataEntity[];
data?: DataEntity[];
monthData: DataEntity[];
groupedData: DataEntity[];
legend: LegendEntity[];
startDate: string;
endDate: string;
groupedData?: DataEntity[];
legend?: LegendEntity[];
startDate?: string;
endDate?: string;
totalDebts: number;
totalAssets: number;
totalTotals: number;

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [carkom]
---
In custom reports: separating "show offbudget" filter to split out hidden categories from offbudget.