mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 01:58:40 -05:00
Summary report (#3792)
* Summary card report * Apply suggestions from code rabbit * Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * MORE CODE RABBIT SUGGESTIONS * typecheck fix * change view form the details page * added privacy filter * Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * debounce * removed binary search and changed the summary page to not use the card component * Update packages/desktop-client/src/components/reports/spreadsheets/summary-spreadsheet.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix on recommended code rabbit commit * added some padding to number so it fits the window better for big numbers * accept infinite * feedback fixes * Update packages/desktop-client/src/components/reports/reports/SummaryCard.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * translations * fix on the save, linter and changed "include summary date range" to "all time divisor" * changed MD from enhancements to feature * typo * change card * typecheck * Update packages/desktop-client/src/components/reports/SummaryNumber.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * typecheck * changes to fit the number better * small fix * fix on filters * code review * revert code to check for height --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -38,8 +38,8 @@ import { CustomReportListCards } from './reports/CustomReportListCards';
|
||||
import { MarkdownCard } from './reports/MarkdownCard';
|
||||
import { NetWorthCard } from './reports/NetWorthCard';
|
||||
import { SpendingCard } from './reports/SpendingCard';
|
||||
|
||||
import './overview.scss';
|
||||
import { SummaryCard } from './reports/SummaryCard';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
@@ -381,6 +381,10 @@ export function Overview() {
|
||||
name: 'markdown-card' as const,
|
||||
text: t('Text widget'),
|
||||
},
|
||||
{
|
||||
name: 'summary-card' as const,
|
||||
text: t('Summary card'),
|
||||
},
|
||||
{
|
||||
name: 'custom-report' as const,
|
||||
text: t('New custom report'),
|
||||
@@ -522,6 +526,14 @@ export function Overview() {
|
||||
report={customReportMap.get(item.meta.id)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
/>
|
||||
) : item.type === 'summary-card' ? (
|
||||
<SummaryCard
|
||||
widgetId={item.i}
|
||||
isEditing={isEditing}
|
||||
meta={item.meta}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CashFlow } from './reports/CashFlow';
|
||||
import { CustomReport } from './reports/CustomReport';
|
||||
import { NetWorth } from './reports/NetWorth';
|
||||
import { Spending } from './reports/Spending';
|
||||
import { Summary } from './reports/Summary';
|
||||
|
||||
export function ReportRouter() {
|
||||
return (
|
||||
@@ -19,6 +20,8 @@ export function ReportRouter() {
|
||||
<Route path="/custom/:id" element={<CustomReport />} />
|
||||
<Route path="/spending" element={<Spending />} />
|
||||
<Route path="/spending/:id" element={<Spending />} />
|
||||
<Route path="/summary" element={<Summary />} />
|
||||
<Route path="/summary/:id" element={<Summary />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
148
packages/desktop-client/src/components/reports/SummaryNumber.tsx
Normal file
148
packages/desktop-client/src/components/reports/SummaryNumber.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { type Ref, useRef, useState } from 'react';
|
||||
|
||||
import { debounce } from 'debounce';
|
||||
|
||||
import { amountToCurrency } from 'loot-core/shared/util';
|
||||
|
||||
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { View } from '../common/View';
|
||||
import { PrivacyFilter } from '../PrivacyFilter';
|
||||
|
||||
import { chartTheme } from './chart-theme';
|
||||
import { LoadingIndicator } from './LoadingIndicator';
|
||||
|
||||
const FONT_SIZE_SCALE_FACTOR = 0.9;
|
||||
const MAX_RECURSION_DEPTH = 10;
|
||||
|
||||
type SummaryNumberProps = {
|
||||
value: number;
|
||||
animate?: boolean;
|
||||
suffix?: string;
|
||||
loading?: boolean;
|
||||
initialFontSize?: number;
|
||||
fontSizeChanged?: (fontSize: number) => void;
|
||||
};
|
||||
|
||||
export function SummaryNumber({
|
||||
value,
|
||||
animate = false,
|
||||
suffix = '',
|
||||
loading = true,
|
||||
initialFontSize = 14,
|
||||
fontSizeChanged,
|
||||
}: SummaryNumberProps) {
|
||||
const [fontSize, setFontSize] = useState<number>(0);
|
||||
const refDiv = useRef<HTMLDivElement>(null);
|
||||
const offScreenRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const adjustFontSizeBinary = (minFontSize: number, maxFontSize: number) => {
|
||||
if (!offScreenRef.current || !refDiv.current) return;
|
||||
|
||||
const offScreenDiv = offScreenRef.current;
|
||||
const refDivCurrent = refDiv.current;
|
||||
|
||||
const binarySearchFontSize = (
|
||||
min: number,
|
||||
max: number,
|
||||
depth: number = 0,
|
||||
) => {
|
||||
if (depth >= MAX_RECURSION_DEPTH) {
|
||||
setFontSize(min);
|
||||
return;
|
||||
}
|
||||
|
||||
const testFontSize = (min + max) / 2;
|
||||
offScreenDiv.style.fontSize = `${testFontSize}px`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const isOverflowing =
|
||||
offScreenDiv.scrollWidth > refDivCurrent.clientWidth ||
|
||||
offScreenDiv.scrollHeight > refDivCurrent.clientHeight;
|
||||
|
||||
if (isOverflowing) {
|
||||
binarySearchFontSize(min, testFontSize, depth + 1);
|
||||
} else {
|
||||
const isUnderflowing =
|
||||
offScreenDiv.scrollWidth <=
|
||||
refDivCurrent.clientWidth * FONT_SIZE_SCALE_FACTOR ||
|
||||
offScreenDiv.scrollHeight <=
|
||||
refDivCurrent.clientHeight * FONT_SIZE_SCALE_FACTOR;
|
||||
|
||||
if (isUnderflowing && testFontSize < max) {
|
||||
binarySearchFontSize(testFontSize, max, depth + 1);
|
||||
} else {
|
||||
setFontSize(testFontSize);
|
||||
if (initialFontSize !== testFontSize && fontSizeChanged) {
|
||||
fontSizeChanged(testFontSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
binarySearchFontSize(minFontSize, maxFontSize);
|
||||
};
|
||||
|
||||
const handleResize = debounce(() => {
|
||||
adjustFontSizeBinary(14, 200);
|
||||
}, 250);
|
||||
|
||||
const ref = useResizeObserver(handleResize);
|
||||
const mergedRef = useMergedRefs(ref, refDiv);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <LoadingIndicator />}
|
||||
{!loading && (
|
||||
<>
|
||||
<div
|
||||
ref={offScreenRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '-999px',
|
||||
top: '-999px',
|
||||
fontSize: `${initialFontSize}px`,
|
||||
lineHeight: 1,
|
||||
visibility: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(Math.abs(value))}
|
||||
{suffix}
|
||||
</PrivacyFilter>
|
||||
</div>
|
||||
|
||||
<View
|
||||
ref={mergedRef as Ref<HTMLDivElement>}
|
||||
role="text"
|
||||
aria-label={`${value < 0 ? 'Negative' : 'Positive'} amount: ${amountToCurrency(Math.abs(value))}${suffix}`}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: 1,
|
||||
padding: 8,
|
||||
justifyContent: 'center',
|
||||
transition: animate ? 'font-size 0.3s ease' : '',
|
||||
color: value < 0 ? chartTheme.colors.red : chartTheme.colors.blue,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(Math.abs(value))}
|
||||
{suffix}
|
||||
</PrivacyFilter>
|
||||
</span>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import React, { useState, useEffect, useMemo, type CSSProperties } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { useWidget } from 'loot-core/client/data-hooks/widget';
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { amountToCurrency } from 'loot-core/shared/util';
|
||||
import { addNotification } from 'loot-core/src/client/actions';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import {
|
||||
type SummaryContent,
|
||||
type SummaryWidget,
|
||||
type TimeFrame,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useFilters } from '../../../hooks/useFilters';
|
||||
import { useNavigate } from '../../../hooks/useNavigate';
|
||||
import { SvgEquals } from '../../../icons/v1';
|
||||
import { SvgCloseParenthesis } from '../../../icons/v2/CloseParenthesis';
|
||||
import { SvgOpenParenthesis } from '../../../icons/v2/OpenParenthesis';
|
||||
import { SvgSum } from '../../../icons/v2/Sum';
|
||||
import { theme } from '../../../style';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import { EditablePageHeaderTitle } from '../../EditablePageHeaderTitle';
|
||||
import { AppliedFilters } from '../../filters/AppliedFilters';
|
||||
import { FilterButton } from '../../filters/FiltersMenu';
|
||||
import { Checkbox } from '../../forms';
|
||||
import { MobileBackButton } from '../../mobile/MobileBackButton';
|
||||
import { FieldSelect } from '../../modals/EditRuleModal';
|
||||
import { MobilePageHeader, Page, PageHeader } from '../../Page';
|
||||
import { PrivacyFilter } from '../../PrivacyFilter';
|
||||
import { useResponsive } from '../../responsive/ResponsiveProvider';
|
||||
import { chartTheme } from '../chart-theme';
|
||||
import { Header } from '../Header';
|
||||
import { LoadingIndicator } from '../LoadingIndicator';
|
||||
import { calculateTimeRange } from '../reportRanges';
|
||||
import { summarySpreadsheet } from '../spreadsheets/summary-spreadsheet';
|
||||
import { useReport } from '../useReport';
|
||||
import { fromDateRepr } from '../util';
|
||||
|
||||
export function Summary() {
|
||||
const params = useParams();
|
||||
const { data: widget, isLoading } = useWidget<SummaryWidget>(
|
||||
params.id ?? '',
|
||||
'summary-card',
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <SummaryInner widget={widget} />;
|
||||
}
|
||||
|
||||
type SummaryInnerProps = {
|
||||
widget?: SummaryWidget;
|
||||
};
|
||||
|
||||
type FilterObject = ReturnType<typeof useFilters>;
|
||||
|
||||
function SummaryInner({ widget }: SummaryInnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
{
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
},
|
||||
);
|
||||
const [start, setStart] = useState(initialStart);
|
||||
const [end, setEnd] = useState(initialEnd);
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
|
||||
const dividendFilters: FilterObject = useFilters(
|
||||
widget?.meta?.conditions ?? [],
|
||||
widget?.meta?.conditionsOp ?? 'and',
|
||||
);
|
||||
|
||||
const [content, setContent] = useState<SummaryContent>(
|
||||
widget?.meta?.content
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(widget.meta.content);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse widget meta content:', error);
|
||||
return {
|
||||
type: 'sum',
|
||||
divisorAllTimeDateRange: false,
|
||||
divisorConditions: [],
|
||||
divisorConditionsOp: 'and',
|
||||
};
|
||||
}
|
||||
})()
|
||||
: {
|
||||
type: 'sum',
|
||||
divisorAllTimeDateRange: false,
|
||||
divisorConditions: [],
|
||||
divisorConditionsOp: 'and',
|
||||
},
|
||||
);
|
||||
|
||||
const divisorFilters = useFilters(
|
||||
content.type === 'percentage' ? (content?.divisorConditions ?? []) : [],
|
||||
content.type === 'percentage'
|
||||
? (content?.divisorConditionsOp ?? 'and')
|
||||
: 'and',
|
||||
);
|
||||
|
||||
const params = useMemo(
|
||||
() =>
|
||||
summarySpreadsheet(
|
||||
start,
|
||||
end,
|
||||
dividendFilters.conditions,
|
||||
dividendFilters.conditionsOp,
|
||||
content,
|
||||
),
|
||||
[
|
||||
start,
|
||||
end,
|
||||
dividendFilters.conditions,
|
||||
dividendFilters.conditionsOp,
|
||||
content,
|
||||
],
|
||||
);
|
||||
|
||||
const data = useReport('summary', params);
|
||||
|
||||
useEffect(() => {
|
||||
setContent(prev => ({
|
||||
...prev,
|
||||
divisorConditions: divisorFilters.conditions,
|
||||
divisorConditionsOp: divisorFilters.conditionsOp,
|
||||
}));
|
||||
}, [divisorFilters.conditions, divisorFilters.conditionsOp]);
|
||||
|
||||
const [allMonths, setAllMonths] = useState<
|
||||
Array<{
|
||||
name: string;
|
||||
pretty: string;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
const trans = await send('get-earliest-transaction');
|
||||
const currentMonth = monthUtils.currentMonth();
|
||||
let earliestMonth = trans
|
||||
? monthUtils.monthFromDate(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();
|
||||
}, []);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const title = widget?.meta?.name || t('Summary');
|
||||
|
||||
const onSaveWidgetName = async (newName: string) => {
|
||||
if (!widget) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: t('Cannot save: No widget available.'),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = newName || t('Summary');
|
||||
await send('dashboard-update-widget', {
|
||||
id: widget.id,
|
||||
meta: {
|
||||
...(widget.meta ?? {}),
|
||||
name,
|
||||
content: JSON.stringify(content),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) {
|
||||
setStart(start);
|
||||
setEnd(end);
|
||||
setMode(mode);
|
||||
}
|
||||
|
||||
async function onSaveWidget() {
|
||||
if (!widget) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: t('Cannot save: No widget available.'),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await send('dashboard-update-widget', {
|
||||
id: widget.id,
|
||||
meta: {
|
||||
...(widget.meta ?? {}),
|
||||
conditions: dividendFilters.conditions,
|
||||
conditionsOp: dividendFilters.conditionsOp,
|
||||
timeFrame: {
|
||||
start,
|
||||
end,
|
||||
mode,
|
||||
},
|
||||
content: JSON.stringify(content),
|
||||
},
|
||||
});
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'message',
|
||||
message: t('Dashboard widget successfully saved.'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
header={
|
||||
isNarrowWidth ? (
|
||||
<MobilePageHeader
|
||||
title={title}
|
||||
leftContent={
|
||||
<MobileBackButton onPress={() => navigate('/reports')} />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PageHeader
|
||||
title={
|
||||
widget ? (
|
||||
<EditablePageHeaderTitle
|
||||
title={title}
|
||||
onSave={onSaveWidgetName}
|
||||
/>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
padding={0}
|
||||
>
|
||||
<Header
|
||||
allMonths={allMonths}
|
||||
start={start}
|
||||
end={end}
|
||||
mode={mode}
|
||||
onChangeDates={onChangeDates}
|
||||
onApply={dividendFilters.onApply}
|
||||
onUpdateFilter={dividendFilters.onUpdate}
|
||||
onDeleteFilter={dividendFilters.onDelete}
|
||||
conditionsOp={dividendFilters.conditionsOp}
|
||||
onConditionsOpChange={dividendFilters.onConditionsOpChange}
|
||||
show1Month={true}
|
||||
>
|
||||
{widget && (
|
||||
<Button variant="primary" onPress={onSaveWidget}>
|
||||
<Trans>Save widget</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Header>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
background: theme.pageBackground,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
alignContent: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 4 }}>{t('Show as')}</span>
|
||||
<FieldSelect
|
||||
style={{ marginRight: 16 }}
|
||||
fields={[
|
||||
['sum', t('Sum')],
|
||||
['avgPerMonth', t('Average per month')],
|
||||
['avgPerTransact', t('Average per transaction')],
|
||||
['percentage', t('Percentage')],
|
||||
]}
|
||||
value={content.type ?? 'sum'}
|
||||
onChange={(
|
||||
newValue: 'sum' | 'avgPerMonth' | 'avgPerTransact' | 'percentage',
|
||||
) =>
|
||||
setContent(
|
||||
(prev: SummaryContent) =>
|
||||
({
|
||||
...prev,
|
||||
type: newValue,
|
||||
}) as SummaryContent,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
{content.type === 'percentage' && (
|
||||
<View style={{ flexDirection: 'row', marginLeft: 16 }}>
|
||||
<Checkbox
|
||||
id="enabled-field"
|
||||
checked={content.divisorAllTimeDateRange ?? false}
|
||||
onChange={() => {
|
||||
const currentValue = content.divisorAllTimeDateRange ?? false;
|
||||
setContent(prev => ({
|
||||
...prev,
|
||||
divisorAllTimeDateRange: !currentValue,
|
||||
}));
|
||||
}}
|
||||
/>{' '}
|
||||
{t('All time divisor')}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
background: theme.pageBackground,
|
||||
padding: 20,
|
||||
paddingTop: 0,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Operator
|
||||
type={content.type}
|
||||
dividendFilterObject={dividendFilters}
|
||||
divisorFilterObject={divisorFilters}
|
||||
showDivisorDateRange={
|
||||
content.type === 'percentage'
|
||||
? !(content.divisorAllTimeDateRange ?? false)
|
||||
: false
|
||||
}
|
||||
fromRange={data?.fromRange ?? ''}
|
||||
toRange={data?.toRange ?? ''}
|
||||
/>
|
||||
{content.type !== 'sum' && (
|
||||
<>
|
||||
<SvgEquals width={50} style={{ marginLeft: 56 }} />
|
||||
<View style={{ padding: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '50px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(data?.dividend ?? 0)}
|
||||
</PrivacyFilter>
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: 32,
|
||||
marginBottom: 32,
|
||||
borderTop: '2px solid',
|
||||
borderBottom: '2px solid',
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '50px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(data?.divisor ?? 0)}
|
||||
</PrivacyFilter>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<SvgEquals width={50} style={{ marginLeft: 16 }} />
|
||||
<View
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
textAlign: 'center',
|
||||
width: '250px',
|
||||
maxWidth: '250px',
|
||||
justifyItems: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 16,
|
||||
fontSize: '50px',
|
||||
justifyContent: 'center',
|
||||
color:
|
||||
(data?.total ?? 0) < 0
|
||||
? chartTheme.colors.red
|
||||
: chartTheme.colors.blue,
|
||||
}}
|
||||
>
|
||||
<PrivacyFilter>
|
||||
{amountToCurrency(Math.abs(data?.total ?? 0))}
|
||||
{content.type === 'percentage' ? '%' : ''}
|
||||
</PrivacyFilter>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
type OperatorProps = {
|
||||
type: 'sum' | 'avgPerMonth' | 'avgPerTransact' | 'percentage';
|
||||
dividendFilterObject: FilterObject;
|
||||
divisorFilterObject: FilterObject;
|
||||
fromRange: string;
|
||||
toRange: string;
|
||||
showDivisorDateRange: boolean;
|
||||
};
|
||||
function Operator({
|
||||
type,
|
||||
dividendFilterObject,
|
||||
divisorFilterObject,
|
||||
fromRange,
|
||||
toRange,
|
||||
showDivisorDateRange,
|
||||
}: OperatorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SumWithRange
|
||||
from={fromRange}
|
||||
to={toRange}
|
||||
filterObject={dividendFilterObject}
|
||||
/>
|
||||
{type === 'percentage' && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: 32,
|
||||
marginBottom: 32,
|
||||
borderTop: '2px solid',
|
||||
borderBottom: '2px solid',
|
||||
}}
|
||||
/>
|
||||
<SumWithRange
|
||||
from={!showDivisorDateRange ? '' : fromRange}
|
||||
to={!showDivisorDateRange ? '' : toRange}
|
||||
filterObject={divisorFilterObject}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{type !== 'percentage' && type !== 'sum' && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: 32,
|
||||
marginBottom: 32,
|
||||
borderTop: '2px solid',
|
||||
borderBottom: '2px solid',
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{ fontSize: '32px', width: '100%', textAlign: 'center' }}
|
||||
>
|
||||
{type === 'avgPerMonth'
|
||||
? t('number of months')
|
||||
: t('number of transactions')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type SumWithRangeProps = {
|
||||
from: string;
|
||||
to: string;
|
||||
containerStyle?: CSSProperties;
|
||||
filterObject: FilterObject;
|
||||
};
|
||||
function SumWithRange({
|
||||
from,
|
||||
to,
|
||||
containerStyle,
|
||||
filterObject,
|
||||
}: SumWithRangeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...containerStyle,
|
||||
height: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 15px 1fr 15px',
|
||||
}}
|
||||
>
|
||||
<View style={{ position: 'relative', height: '50px', marginRight: 50 }}>
|
||||
<SvgSum width={50} height={50} />
|
||||
<Text style={{ position: 'absolute', right: -30, top: -20 }}>{to}</Text>
|
||||
<Text style={{ position: 'absolute', right: -30, bottom: -20 }}>
|
||||
{from}
|
||||
</Text>
|
||||
</View>
|
||||
<SvgOpenParenthesis width={15} style={{ height: '100%' }} />
|
||||
<View style={{ marginLeft: 16, maxWidth: '220px', marginRight: 16 }}>
|
||||
{(filterObject.conditions?.length ?? 0) === 0 ? (
|
||||
<Text style={{ fontSize: '25px', color: theme.pageTextPositive }}>
|
||||
{t('all transactions')}
|
||||
</Text>
|
||||
) : (
|
||||
<AppliedFilters
|
||||
conditions={filterObject.conditions}
|
||||
onUpdate={filterObject.onUpdate}
|
||||
onDelete={filterObject.onDelete}
|
||||
conditionsOp={filterObject.conditionsOp}
|
||||
onConditionsOpChange={filterObject.onConditionsOpChange}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<SvgCloseParenthesis width={15} style={{ height: '100%' }} />
|
||||
<View style={{ position: 'absolute', top: -15, right: -55 }}>
|
||||
<FilterButton
|
||||
compact={false}
|
||||
onApply={filterObject.onApply}
|
||||
hover={false}
|
||||
exclude={undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import {
|
||||
type SummaryContent,
|
||||
type SummaryWidget,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { View } from '../../common/View';
|
||||
import { DateRange } from '../DateRange';
|
||||
import { LoadingIndicator } from '../LoadingIndicator';
|
||||
import { ReportCard } from '../ReportCard';
|
||||
import { ReportCardName } from '../ReportCardName';
|
||||
import { calculateTimeRange } from '../reportRanges';
|
||||
import { summarySpreadsheet } from '../spreadsheets/summary-spreadsheet';
|
||||
import { SummaryNumber } from '../SummaryNumber';
|
||||
import { useReport } from '../useReport';
|
||||
|
||||
type SummaryCardProps = {
|
||||
widgetId: string;
|
||||
isEditing?: boolean;
|
||||
meta?: SummaryWidget['meta'];
|
||||
onMetaChange: (newMeta: SummaryWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
export function SummaryCard({
|
||||
widgetId,
|
||||
isEditing,
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
}: SummaryCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [start, end] = calculateTimeRange(meta?.timeFrame, {
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
});
|
||||
|
||||
const content = useMemo(
|
||||
() =>
|
||||
(meta?.content
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(meta.content);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse meta.content:', error);
|
||||
return { type: 'sum' };
|
||||
}
|
||||
})()
|
||||
: { type: 'sum' }) as SummaryContent,
|
||||
[meta],
|
||||
);
|
||||
|
||||
const params = useMemo(
|
||||
() =>
|
||||
summarySpreadsheet(
|
||||
start,
|
||||
end,
|
||||
meta?.conditions,
|
||||
meta?.conditionsOp,
|
||||
content,
|
||||
),
|
||||
[start, end, meta?.conditions, meta?.conditionsOp, content],
|
||||
);
|
||||
|
||||
const data = useReport('summary', params);
|
||||
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
to={`/reports/summary/${widgetId}`}
|
||||
menuItems={[
|
||||
{
|
||||
name: 'rename',
|
||||
text: t('Rename'),
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
setNameMenuOpen(true);
|
||||
break;
|
||||
case 'remove':
|
||||
onRemove();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unrecognized menu selection: ${item}`);
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<View style={{ flexGrow: 0, flexShrink: 0, padding: 20 }}>
|
||||
<ReportCardName
|
||||
name={meta?.name || t('Summary')}
|
||||
isEditing={nameMenuOpen}
|
||||
onChange={newName => {
|
||||
onMetaChange({
|
||||
...meta,
|
||||
content: JSON.stringify(content),
|
||||
name: newName,
|
||||
});
|
||||
setNameMenuOpen(false);
|
||||
}}
|
||||
onClose={() => setNameMenuOpen(false)}
|
||||
/>
|
||||
<DateRange start={start} end={end} />
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
>
|
||||
{data ? (
|
||||
<SummaryNumber
|
||||
value={data?.total ?? 0}
|
||||
suffix={content.type === 'percentage' ? '%' : ''}
|
||||
loading={!data}
|
||||
initialFontSize={content.fontSize}
|
||||
fontSizeChanged={newSize => {
|
||||
const newContent = { ...content, fontSize: newSize };
|
||||
onMetaChange({
|
||||
...meta,
|
||||
content: JSON.stringify(newContent),
|
||||
});
|
||||
}}
|
||||
animate={isEditing ?? false}
|
||||
/>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import * as d from 'date-fns';
|
||||
|
||||
import { runQuery } from 'loot-core/src/client/query-helpers';
|
||||
import { type useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { q } from 'loot-core/src/shared/query';
|
||||
import {
|
||||
type SummaryContent,
|
||||
type RuleConditionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
export function summarySpreadsheet(
|
||||
start: string,
|
||||
end: string,
|
||||
conditions: RuleConditionEntity[] = [],
|
||||
conditionsOp: 'and' | 'or' = 'and',
|
||||
summaryContent: SummaryContent,
|
||||
) {
|
||||
return async (
|
||||
spreadsheet: ReturnType<typeof useSpreadsheet>,
|
||||
setData: (data: {
|
||||
total: number;
|
||||
divisor: number;
|
||||
dividend: number;
|
||||
fromRange: string;
|
||||
toRange: string;
|
||||
}) => void,
|
||||
) => {
|
||||
let filters = [];
|
||||
try {
|
||||
const response = await send('make-filters-from-conditions', {
|
||||
conditions: conditions.filter(cond => !cond.customName),
|
||||
});
|
||||
filters = response.filters;
|
||||
} catch (error) {
|
||||
console.error('Error fetching filters:', error);
|
||||
}
|
||||
const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and';
|
||||
|
||||
let startDay: Date;
|
||||
let endDay: Date;
|
||||
try {
|
||||
startDay = d.parse(
|
||||
monthUtils.firstDayOfMonth(start),
|
||||
'yyyy-MM-dd',
|
||||
new Date(),
|
||||
);
|
||||
|
||||
endDay = d.parse(
|
||||
monthUtils.lastDayOfMonth(end),
|
||||
'yyyy-MM-dd',
|
||||
new Date(),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error parsing dates:', error);
|
||||
throw new Error('Invalid date format provided');
|
||||
}
|
||||
|
||||
if (!d.isValid(startDay) || !d.isValid(endDay)) {
|
||||
throw new Error('Invalid date values provided');
|
||||
}
|
||||
|
||||
if (d.isAfter(startDay, endDay)) {
|
||||
throw new Error('Start date must be before or equal to end date.');
|
||||
}
|
||||
|
||||
const getOneDatePerMonth = (start: Date, end: Date) => {
|
||||
const months = [];
|
||||
let currentDate = d.startOfMonth(start);
|
||||
|
||||
while (!d.isSameMonth(currentDate, end)) {
|
||||
months.push(currentDate);
|
||||
currentDate = d.addMonths(currentDate, 1);
|
||||
}
|
||||
months.push(end);
|
||||
|
||||
return months;
|
||||
};
|
||||
|
||||
const makeRootQuery = () =>
|
||||
q('transactions')
|
||||
.filter({
|
||||
$and: [
|
||||
{
|
||||
date: {
|
||||
$gte: d.format(startDay, 'yyyy-MM-dd'),
|
||||
},
|
||||
},
|
||||
{
|
||||
date: {
|
||||
$lte: d.format(endDay, 'yyyy-MM-dd'),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.filter({
|
||||
[conditionsOpKey]: filters,
|
||||
})
|
||||
.select([
|
||||
'date',
|
||||
{ amount: { $sum: '$amount' } },
|
||||
{ count: { $count: '*' } },
|
||||
]);
|
||||
|
||||
let query = makeRootQuery();
|
||||
|
||||
if (summaryContent.type === 'avgPerMonth') {
|
||||
query = query.groupBy(['date']);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await runQuery(query);
|
||||
} catch (error) {
|
||||
console.error('Error executing query:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const dateRanges = {
|
||||
fromRange: d.format(startDay, 'MMM yy'),
|
||||
toRange: d.format(endDay, 'MMM yy'),
|
||||
};
|
||||
|
||||
switch (summaryContent.type) {
|
||||
case 'sum':
|
||||
setData({
|
||||
...dateRanges,
|
||||
total: (data.data[0]?.amount ?? 0) / 100,
|
||||
dividend: (data.data[0]?.amount ?? 0) / 100,
|
||||
divisor: 0,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'avgPerTransact':
|
||||
setData({
|
||||
...dateRanges,
|
||||
total:
|
||||
((data.data[0]?.count ?? 0)
|
||||
? (data.data[0]?.amount ?? 0) / data.data[0].count
|
||||
: 0) / 100,
|
||||
dividend: (data.data[0]?.amount ?? 0) / 100,
|
||||
divisor: data.data[0].count,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'avgPerMonth': {
|
||||
const months = getOneDatePerMonth(startDay, endDay);
|
||||
setData({ ...dateRanges, ...calculatePerMonth(data.data, months) });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'percentage':
|
||||
setData({
|
||||
...dateRanges,
|
||||
...(await calculatePercentage(
|
||||
data.data,
|
||||
summaryContent,
|
||||
startDay,
|
||||
endDay,
|
||||
)),
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported summary type`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function calculatePerMonth(
|
||||
data: Array<{
|
||||
date: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
}>,
|
||||
months: Date[],
|
||||
) {
|
||||
if (!data.length || !months.length) {
|
||||
return { total: 0, dividend: 0, divisor: 0 };
|
||||
}
|
||||
|
||||
const monthlyData = data.reduce(
|
||||
(acc, day) => {
|
||||
const monthKey = d.format(
|
||||
d.parse(day.date, 'yyyy-MM-dd', new Date()),
|
||||
'yyyy-MM',
|
||||
);
|
||||
acc[monthKey] = (acc[monthKey] || 0) + day.amount;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const monthsSum = months.map(m => ({
|
||||
amount: monthlyData[d.format(m, 'yyyy-MM')] || 0,
|
||||
}));
|
||||
|
||||
const totalAmount = monthsSum.reduce((sum, month) => sum + month.amount, 0);
|
||||
const averageAmountPerMonth = totalAmount / months.length;
|
||||
|
||||
return {
|
||||
total: averageAmountPerMonth / 100,
|
||||
dividend: totalAmount / 100,
|
||||
divisor: months.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function calculatePercentage(
|
||||
data: Array<{
|
||||
amount: number;
|
||||
}>,
|
||||
summaryContent: SummaryContent,
|
||||
startDay: Date,
|
||||
endDay: Date,
|
||||
) {
|
||||
if (summaryContent.type !== 'percentage') {
|
||||
return {
|
||||
total: 0,
|
||||
dividend: 0,
|
||||
divisor: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const conditionsOpKey =
|
||||
summaryContent.divisorConditionsOp === 'or' ? '$or' : '$and';
|
||||
let filters = [];
|
||||
try {
|
||||
const response = await send('make-filters-from-conditions', {
|
||||
conditions: summaryContent?.divisorConditions?.filter(
|
||||
cond => !cond.customName,
|
||||
),
|
||||
});
|
||||
filters = response.filters;
|
||||
} catch (error) {
|
||||
console.error('Error creating filters:', error);
|
||||
return {
|
||||
total: 0,
|
||||
dividend: 0,
|
||||
divisor: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const makeDivisorQuery = () =>
|
||||
q('transactions')
|
||||
.filter({
|
||||
[conditionsOpKey]: filters,
|
||||
})
|
||||
.select([{ amount: { $sum: '$amount' } }]);
|
||||
|
||||
let query = makeDivisorQuery();
|
||||
|
||||
if (!(summaryContent.divisorAllTimeDateRange ?? false)) {
|
||||
query = query.filter({
|
||||
$and: [
|
||||
{
|
||||
date: {
|
||||
$gte: d.format(startDay, 'yyyy-MM-dd'),
|
||||
},
|
||||
},
|
||||
{
|
||||
date: {
|
||||
$lte: d.format(endDay, 'yyyy-MM-dd'),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
let divisorData;
|
||||
try {
|
||||
divisorData = (await runQuery(query)) as { data: { amount: number }[] };
|
||||
} catch (error) {
|
||||
console.error('Error executing divisor query:', error);
|
||||
return {
|
||||
total: 0,
|
||||
dividend: 0,
|
||||
divisor: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const divisorValue = divisorData?.data?.[0]?.amount ?? 0;
|
||||
|
||||
const dividend = data.reduce((prev, ac) => prev + (ac?.amount ?? 0), 0);
|
||||
return {
|
||||
total: Math.round(((dividend ?? 0) / (divisorValue ?? 1)) * 10000) / 100,
|
||||
divisor: (divisorValue ?? 0) / 100,
|
||||
dividend: (dividend ?? 0) / 100,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 17 56.7" style="enable-background:new 0 0 17 56.7;" xml:space="preserve">
|
||||
<path d="M1.9,2.2L1.9,2.2c1.3,0.2,2.4,0.7,3.4,1.5c0.8,0.8,1.5,1.8,2,2.9c0.9,2.3,3.1,8.4,3.1,21.8S8.2,47.8,7.3,50.1
|
||||
c-0.5,1.1-1.2,2.1-2,2.9c-1,0.8-2.1,1.3-3.3,1.5H1.9c-0.4,0.1-0.6,0.4-0.6,0.8c0.1,0.3,0.3,0.6,0.6,0.6c1.6,0.2,3.2-0.1,4.7-0.8
|
||||
c1.4-0.8,2.7-1.9,3.6-3.2c1.7-2.6,2.9-5.4,3.6-8.4l0.1-0.3c2.5-9.7,2.5-19.8,0-29.5l-0.1-0.3c-0.7-3-1.9-5.8-3.6-8.4
|
||||
C9.3,3.5,8,2.4,6.6,1.6C5.1,0.9,3.5,0.7,1.9,0.9c-0.4,0-0.7,0.4-0.6,0.7C1.3,1.9,1.5,2.2,1.9,2.2L1.9,2.2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 817 B |
22
packages/desktop-client/src/icons/v2/CloseParenthesis.tsx
Normal file
22
packages/desktop-client/src/icons/v2/CloseParenthesis.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
export const SvgCloseParenthesis = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 17 56.7"
|
||||
preserveAspectRatio='none'
|
||||
style={{
|
||||
color: 'inherit',
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M1.9,2.2L1.9,2.2c1.3,0.2,2.4,0.7,3.4,1.5c0.8,0.8,1.5,1.8,2,2.9c0.9,2.3,3.1,8.4,3.1,21.8S8.2,47.8,7.3,50.1
|
||||
c-0.5,1.1-1.2,2.1-2,2.9c-1,0.8-2.1,1.3-3.3,1.5H1.9c-0.4,0.1-0.6,0.4-0.6,0.8c0.1,0.3,0.3,0.6,0.6,0.6c1.6,0.2,3.2-0.1,4.7-0.8
|
||||
c1.4-0.8,2.7-1.9,3.6-3.2c1.7-2.6,2.9-5.4,3.6-8.4l0.1-0.3c2.5-9.7,2.5-19.8,0-29.5l-0.1-0.3c-0.7-3-1.9-5.8-3.6-8.4
|
||||
C9.3,3.5,8,2.4,6.6,1.6C5.1,0.9,3.5,0.7,1.9,0.9c-0.4,0-0.7,0.4-0.6,0.7C1.3,1.9,1.5,2.2,1.9,2.2L1.9,2.2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
9
packages/desktop-client/src/icons/v2/OpenParenthesis.svg
Normal file
9
packages/desktop-client/src/icons/v2/OpenParenthesis.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 17 56.7" style="enable-background:new 0 0 17 56.7;" xml:space="preserve">
|
||||
<path d="M15.1,54.5L15.1,54.5c-1.3-0.2-2.4-0.7-3.4-1.5c-0.8-0.8-1.5-1.8-2-2.9c-0.9-2.3-3.1-8.4-3.1-21.8S8.8,8.9,9.8,6.6
|
||||
c0.5-1.1,1.2-2.1,2-2.9c1-0.8,2.1-1.3,3.3-1.5h0.1c0.4-0.1,0.6-0.4,0.6-0.8c-0.1-0.3-0.3-0.6-0.6-0.6c-1.6-0.2-3.2,0.1-4.7,0.8
|
||||
C9,2.4,7.8,3.5,6.8,4.8c-1.7,2.6-2.9,5.4-3.6,8.4l-0.1,0.3c-2.5,9.7-2.5,19.8,0,29.5l0.1,0.3c0.7,3,1.9,5.8,3.6,8.4
|
||||
c0.9,1.3,2.2,2.4,3.6,3.2c1.5,0.7,3.1,0.9,4.7,0.8c0.4,0,0.7-0.4,0.6-0.7C15.7,54.8,15.5,54.5,15.1,54.5L15.1,54.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 829 B |
22
packages/desktop-client/src/icons/v2/OpenParenthesis.tsx
Normal file
22
packages/desktop-client/src/icons/v2/OpenParenthesis.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
export const SvgOpenParenthesis = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 17 56.7"
|
||||
preserveAspectRatio='none'
|
||||
style={{
|
||||
color: 'inherit',
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M15.1,54.5L15.1,54.5c-1.3-0.2-2.4-0.7-3.4-1.5c-0.8-0.8-1.5-1.8-2-2.9c-0.9-2.3-3.1-8.4-3.1-21.8S8.8,8.9,9.8,6.6
|
||||
c0.5-1.1,1.2-2.1,2-2.9c1-0.8,2.1-1.3,3.3-1.5h0.1c0.4-0.1,0.6-0.4,0.6-0.8c-0.1-0.3-0.3-0.6-0.6-0.6c-1.6-0.2-3.2,0.1-4.7,0.8
|
||||
C9,2.4,7.8,3.5,6.8,4.8c-1.7,2.6-2.9,5.4-3.6,8.4l-0.1,0.3c-2.5,9.7-2.5,19.8,0,29.5l0.1,0.3c0.7,3,1.9,5.8,3.6,8.4
|
||||
c0.9,1.3,2.2,2.4,3.6,3.2c1.5,0.7,3.1,0.9,4.7,0.8c0.4,0,0.7-0.4,0.6-0.7C15.7,54.8,15.5,54.5,15.1,54.5L15.1,54.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
11
packages/desktop-client/src/icons/v2/Sum.svg
Normal file
11
packages/desktop-client/src/icons/v2/Sum.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" baseProfile="basic" id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 28.3 28.3"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<path d="M23.2,10.1c1.1,0,2-0.9,2-2V2.2c0-1.1-0.9-2-2-2h-18c-1.1,0-2,0.9-2,2c0,0.4,0.1,0.9,0.4,1.2l8.1,10.8L3.6,25
|
||||
c-0.7,0.9-0.5,2.1,0.4,2.8c0.3,0.3,0.8,0.4,1.2,0.4h18c1.1,0,2-0.9,2-2v-5.8c0-1.1-0.9-2-2-2s-2,0.9-2,2v3.8h-12l6.6-8.8
|
||||
c0.5-0.7,0.5-1.7,0-2.4L9.2,4.2h12v3.9C21.2,9.2,22.1,10.1,23.2,10.1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 654 B |
20
packages/desktop-client/src/icons/v2/Sum.tsx
Normal file
20
packages/desktop-client/src/icons/v2/Sum.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
export const SvgSum = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 28.3 28.3"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M23.2,10.1c1.1,0,2-0.9,2-2V2.2c0-1.1-0.9-2-2-2h-18c-1.1,0-2,0.9-2,2c0,0.4,0.1,0.9,0.4,1.2l8.1,10.8L3.6,25
|
||||
c-0.7,0.9-0.5,2.1,0.4,2.8c0.3,0.3,0.8,0.4,1.2,0.4h18c1.1,0,2-0.9,2-2v-5.8c0-1.1-0.9-2-2-2s-2,0.9-2,2v3.8h-12l6.6-8.8
|
||||
c0.5-0.7,0.5-1.7,0-2.4L9.2,4.2h12v3.9C21.2,9.2,22.1,10.1,23.2,10.1z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -65,7 +65,8 @@ type SpecializedWidget =
|
||||
| NetWorthWidget
|
||||
| CashFlowWidget
|
||||
| SpendingWidget
|
||||
| MarkdownWidget;
|
||||
| MarkdownWidget
|
||||
| SummaryWidget;
|
||||
export type Widget = SpecializedWidget | CustomReportWidget;
|
||||
export type NewWidget = Omit<Widget, 'id' | 'tombstone'>;
|
||||
|
||||
@@ -88,3 +89,29 @@ export type ExportImportDashboard = {
|
||||
version: 1;
|
||||
widgets: ExportImportDashboardWidget[];
|
||||
};
|
||||
|
||||
export type SummaryWidget = AbstractWidget<
|
||||
'summary-card',
|
||||
{
|
||||
name?: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
timeFrame?: TimeFrame;
|
||||
content?: string;
|
||||
} | null
|
||||
>;
|
||||
|
||||
export type BaseSummaryContent = {
|
||||
type: 'sum' | 'avgPerMonth' | 'avgPerTransact';
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
export type PercentageSummaryContent = {
|
||||
type: 'percentage';
|
||||
divisorConditions: RuleConditionEntity[];
|
||||
divisorConditionsOp: 'and' | 'or';
|
||||
divisorAllTimeDateRange?: boolean;
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
export type SummaryContent = BaseSummaryContent | PercentageSummaryContent;
|
||||
|
||||
6
upcoming-release-notes/3792.md
Normal file
6
upcoming-release-notes/3792.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Add a summary card to report dashboard
|
||||
Reference in New Issue
Block a user