Add Report pages (#6411)
* Adding multiple report pages * Adding release notes * Updating release note number * Update VRT screenshots Auto-generated by VRT workflow PR: #6411 * Fixing deletion id, allowing empty dashboard name, adding custom report dashboard saving, new dashboard default to empty * Update VRT snapshots for command bar, payees, and schedules tests * Update VRT snapshots for payees page visuals and search functionality tests * Towards move/copy logic (need widget meta copy still!) * refactor move widget to use add and remove * Move/Copy modal * fixes for rename duplicate calls, rename focus issue, and deletion undefined issue * Update VRT screenshots Auto-generated by VRT workflow PR: #6411 * some bug/clarity fixes * better type discipline, dashboard_pages schema, PR review fixes * re-org of dashboard pages into dropdown, better mobile support, rename moved to title icon * dashboard spacing fix (even for ridiculously long names), widget type-checking function * Fix translation interpolation * Fixing copy vs. move filename, removing old rename modal, minor review tweaks * overview change simplification, routing error handling, move -> copy migration * renaming for dashboard pages and error handling * abstracting out `isWidgetType` function * Update VRT screenshots Auto-generated by VRT workflow PR: #6411 * Reorganizing dashboard selector and vertical separator, fix widget tombstoning and undoability * [autofix.ci] apply automated fixes * Update VRT screenshots Auto-generated by VRT workflow PR: #6411 * fix dashboard not found spinner, fix dashboard deletion redirect, add SaveReportWrapper * fix some deletion navigation issues and idioms * Update VRT screenshots Auto-generated by VRT workflow PR: #6411 * Translate 'modified' status in SaveReport component * [autofix.ci] apply automated fixes --------- Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 95 KiB |
@@ -20,6 +20,7 @@ import { ConfirmDeleteModal } from './modals/ConfirmDeleteModal';
|
|||||||
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
|
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
|
||||||
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
|
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
|
||||||
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
|
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
|
||||||
|
import { CopyWidgetToDashboardModal } from './modals/CopyWidgetToDashboardModal';
|
||||||
import { CoverModal } from './modals/CoverModal';
|
import { CoverModal } from './modals/CoverModal';
|
||||||
import { CreateAccountModal } from './modals/CreateAccountModal';
|
import { CreateAccountModal } from './modals/CreateAccountModal';
|
||||||
import { CreateEncryptionKeyModal } from './modals/CreateEncryptionKeyModal';
|
import { CreateEncryptionKeyModal } from './modals/CreateEncryptionKeyModal';
|
||||||
@@ -148,6 +149,9 @@ export function Modals() {
|
|||||||
case 'confirm-delete':
|
case 'confirm-delete':
|
||||||
return <ConfirmDeleteModal key={key} {...modal.options} />;
|
return <ConfirmDeleteModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
|
case 'copy-widget-to-dashboard':
|
||||||
|
return <CopyWidgetToDashboardModal key={key} {...modal.options} />;
|
||||||
|
|
||||||
case 'load-backup':
|
case 'load-backup':
|
||||||
return (
|
return (
|
||||||
<LoadBackupModal
|
<LoadBackupModal
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useMemo, type ComponentProps } from 'react';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '@actual-app/components/button';
|
||||||
|
import { Menu } from '@actual-app/components/menu';
|
||||||
|
import { View } from '@actual-app/components/view';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalHeader,
|
||||||
|
} from '@desktop-client/components/common/Modal';
|
||||||
|
import { useDashboardPages } from '@desktop-client/hooks/useDashboard';
|
||||||
|
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||||
|
|
||||||
|
type CopyWidgetToDashboardModalProps = Extract<
|
||||||
|
ModalType,
|
||||||
|
{ name: 'copy-widget-to-dashboard' }
|
||||||
|
>['options'];
|
||||||
|
|
||||||
|
export function CopyWidgetToDashboardModal({
|
||||||
|
onSelect,
|
||||||
|
}: CopyWidgetToDashboardModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: dashboard_pages = [] } = useDashboardPages();
|
||||||
|
|
||||||
|
const items: ComponentProps<typeof Menu<string>>['items'] = useMemo(
|
||||||
|
() => dashboard_pages.map(d => ({ name: d.id, text: d.name })),
|
||||||
|
[dashboard_pages],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal name="copy-widget-to-dashboard">
|
||||||
|
{({ state: { close } }) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader
|
||||||
|
title={t('Copy to dashboard')}
|
||||||
|
rightContent={<ModalCloseButton onPress={close} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ lineHeight: 1.5 }}>
|
||||||
|
{items.length ? (
|
||||||
|
<Menu
|
||||||
|
items={items}
|
||||||
|
onMenuSelect={item => {
|
||||||
|
onSelect(item);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
<Trans>No other dashboard pages available.</Trans>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onPress={close}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation, Trans } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '@actual-app/components/button';
|
||||||
|
import { SvgPencil1 } from '@actual-app/components/icons/v2';
|
||||||
|
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||||
|
import { Input } from '@actual-app/components/input';
|
||||||
|
import { theme } from '@actual-app/components/theme';
|
||||||
|
import { View } from '@actual-app/components/view';
|
||||||
|
|
||||||
|
import { send } from 'loot-core/platform/client/fetch';
|
||||||
|
import { type DashboardEntity } from 'loot-core/types/models';
|
||||||
|
|
||||||
|
type DashboardHeaderProps = {
|
||||||
|
dashboard: DashboardEntity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardHeader({ dashboard }: DashboardHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
|
||||||
|
const handleSaveName = async (newName: string) => {
|
||||||
|
const trimmedName = newName.trim();
|
||||||
|
if (!trimmedName || trimmedName === dashboard.name) {
|
||||||
|
setEditingName(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await send('dashboard-rename', {
|
||||||
|
id: dashboard.id,
|
||||||
|
name: trimmedName,
|
||||||
|
});
|
||||||
|
setEditingName(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
marginLeft: 20,
|
||||||
|
gap: 3,
|
||||||
|
'& .hover-visible': {
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity .25s',
|
||||||
|
},
|
||||||
|
'&:hover .hover-visible': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexBasis: 'auto',
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
fontSize: 25,
|
||||||
|
fontWeight: 500,
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Reports</Trans>:
|
||||||
|
</View>
|
||||||
|
{editingName ? (
|
||||||
|
<InitialFocus>
|
||||||
|
<Input
|
||||||
|
defaultValue={dashboard.name}
|
||||||
|
onEnter={handleSaveName}
|
||||||
|
onUpdate={handleSaveName}
|
||||||
|
onEscape={() => setEditingName(false)}
|
||||||
|
style={{
|
||||||
|
fontSize: 25,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginTop: -3,
|
||||||
|
marginBottom: -4,
|
||||||
|
paddingTop: 2,
|
||||||
|
paddingBottom: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</InitialFocus>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
fontSize: 25,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginRight: 5,
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexBasis: 'auto',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dashboard.name}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
variant="bare"
|
||||||
|
aria-label={t('Rename dashboard')}
|
||||||
|
className="hover-visible"
|
||||||
|
style={{
|
||||||
|
marginRight: 5,
|
||||||
|
}}
|
||||||
|
onPress={() => setEditingName(true)}
|
||||||
|
>
|
||||||
|
<SvgPencil1
|
||||||
|
style={{
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: 'auto',
|
||||||
|
color: theme.pageTextSubdued,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '@actual-app/components/button';
|
||||||
|
import { SvgExpandArrow } from '@actual-app/components/icons/v0';
|
||||||
|
import { Menu } from '@actual-app/components/menu';
|
||||||
|
import { Popover } from '@actual-app/components/popover';
|
||||||
|
import { View } from '@actual-app/components/view';
|
||||||
|
|
||||||
|
import { send } from 'loot-core/platform/client/fetch';
|
||||||
|
import { type DashboardEntity } from 'loot-core/types/models';
|
||||||
|
|
||||||
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||||
|
|
||||||
|
type DashboardSelectorProps = {
|
||||||
|
dashboards: readonly DashboardEntity[];
|
||||||
|
currentDashboard: DashboardEntity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardSelector({
|
||||||
|
dashboards,
|
||||||
|
currentDashboard,
|
||||||
|
}: DashboardSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleAddDashboard = async () => {
|
||||||
|
const defaultName = t('New dashboard');
|
||||||
|
const newId = await send('dashboard-create', { name: defaultName });
|
||||||
|
if (newId) {
|
||||||
|
navigate(`/reports/${newId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
onPress={() => setMenuOpen(true)}
|
||||||
|
style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexBasis: 'auto',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexBasis: 'auto',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
minWidth: 0,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentDashboard.name}
|
||||||
|
</View>
|
||||||
|
<SvgExpandArrow
|
||||||
|
width={7}
|
||||||
|
height={7}
|
||||||
|
style={{
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: 'auto',
|
||||||
|
marginLeft: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<Popover
|
||||||
|
triggerRef={triggerRef}
|
||||||
|
isOpen
|
||||||
|
onOpenChange={setMenuOpen}
|
||||||
|
placement="bottom start"
|
||||||
|
>
|
||||||
|
<Dialog>
|
||||||
|
<Menu
|
||||||
|
slot="close"
|
||||||
|
onMenuSelect={item => {
|
||||||
|
if (item === 'add-new') {
|
||||||
|
handleAddDashboard();
|
||||||
|
} else {
|
||||||
|
navigate(`/reports/${item}`);
|
||||||
|
}
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
...dashboards.map(dashboard => ({
|
||||||
|
name: dashboard.id,
|
||||||
|
text: dashboard.name,
|
||||||
|
})),
|
||||||
|
Menu.line,
|
||||||
|
{
|
||||||
|
name: 'add-new',
|
||||||
|
text: t('Add new dashboard'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Dialog, DialogTrigger } from 'react-aria-components';
|
import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||||
import { Responsive, WidthProvider, type Layout } from 'react-grid-layout';
|
import { Responsive, WidthProvider, type Layout } from 'react-grid-layout';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
@@ -10,18 +10,22 @@ import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
|||||||
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
|
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
|
||||||
import { Menu } from '@actual-app/components/menu';
|
import { Menu } from '@actual-app/components/menu';
|
||||||
import { Popover } from '@actual-app/components/popover';
|
import { Popover } from '@actual-app/components/popover';
|
||||||
|
import { theme } from '@actual-app/components/theme';
|
||||||
import { breakpoints } from '@actual-app/components/tokens';
|
import { breakpoints } from '@actual-app/components/tokens';
|
||||||
import { View } from '@actual-app/components/view';
|
import { View } from '@actual-app/components/view';
|
||||||
|
|
||||||
import { send } from 'loot-core/platform/client/fetch';
|
import { send } from 'loot-core/platform/client/fetch';
|
||||||
import {
|
import type {
|
||||||
type CustomReportWidget,
|
CustomReportWidget,
|
||||||
type ExportImportDashboard,
|
DashboardEntity,
|
||||||
type MarkdownWidget,
|
ExportImportDashboard,
|
||||||
type Widget,
|
MarkdownWidget,
|
||||||
|
Widget,
|
||||||
} from 'loot-core/types/models';
|
} from 'loot-core/types/models';
|
||||||
|
|
||||||
import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
|
import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
|
||||||
|
import { DashboardHeader } from './DashboardHeader';
|
||||||
|
import { DashboardSelector } from './DashboardSelector';
|
||||||
import { LoadingIndicator } from './LoadingIndicator';
|
import { LoadingIndicator } from './LoadingIndicator';
|
||||||
import { CalendarCard } from './reports/CalendarCard';
|
import { CalendarCard } from './reports/CalendarCard';
|
||||||
import { CashFlowCard } from './reports/CashFlowCard';
|
import { CashFlowCard } from './reports/CashFlowCard';
|
||||||
@@ -35,13 +39,12 @@ import './overview.scss';
|
|||||||
import { SummaryCard } from './reports/SummaryCard';
|
import { SummaryCard } from './reports/SummaryCard';
|
||||||
|
|
||||||
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
|
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
|
||||||
import {
|
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||||
MobilePageHeader,
|
|
||||||
Page,
|
|
||||||
PageHeader,
|
|
||||||
} from '@desktop-client/components/Page';
|
|
||||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||||
import { useDashboard } from '@desktop-client/hooks/useDashboard';
|
import {
|
||||||
|
useDashboard,
|
||||||
|
useDashboardPages,
|
||||||
|
} from '@desktop-client/hooks/useDashboard';
|
||||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||||
import { useReports } from '@desktop-client/hooks/useReports';
|
import { useReports } from '@desktop-client/hooks/useReports';
|
||||||
@@ -59,7 +62,11 @@ function isCustomReportWidget(widget: Widget): widget is CustomReportWidget {
|
|||||||
return widget.type === 'custom-report';
|
return widget.type === 'custom-report';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Overview() {
|
type OverviewProps = {
|
||||||
|
dashboard: DashboardEntity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Overview({ dashboard }: OverviewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||||
@@ -76,12 +83,16 @@ export function Overview() {
|
|||||||
|
|
||||||
const { data: customReports, isLoading: isCustomReportsLoading } =
|
const { data: customReports, isLoading: isCustomReportsLoading } =
|
||||||
useReports();
|
useReports();
|
||||||
const { data: widgets, isLoading: isWidgetsLoading } = useDashboard();
|
|
||||||
|
|
||||||
const customReportMap = useMemo(
|
const customReportMap = useMemo(
|
||||||
() => new Map(customReports.map(report => [report.id, report])),
|
() => new Map(customReports.map(report => [report.id, report])),
|
||||||
[customReports],
|
[customReports],
|
||||||
);
|
);
|
||||||
|
const { data: dashboard_pages = [] } = useDashboardPages();
|
||||||
|
|
||||||
|
const { data: widgets, isLoading: isWidgetsLoading } = useDashboard(
|
||||||
|
dashboard.id,
|
||||||
|
);
|
||||||
|
|
||||||
const isLoading = isCustomReportsLoading || isWidgetsLoading;
|
const isLoading = isCustomReportsLoading || isWidgetsLoading;
|
||||||
|
|
||||||
@@ -179,7 +190,7 @@ export function Overview() {
|
|||||||
|
|
||||||
const onResetDashboard = async () => {
|
const onResetDashboard = async () => {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
await send('dashboard-reset');
|
await send('dashboard-reset', dashboard.id);
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
|
|
||||||
onDispatchSucessNotification(
|
onDispatchSucessNotification(
|
||||||
@@ -215,6 +226,7 @@ export function Overview() {
|
|||||||
width: 4,
|
width: 4,
|
||||||
height: 2,
|
height: 2,
|
||||||
meta,
|
meta,
|
||||||
|
dashboard_page_id: dashboard.id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,7 +300,10 @@ export function Overview() {
|
|||||||
|
|
||||||
closeNotifications();
|
closeNotifications();
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
const res = await send('dashboard-import', { filepath });
|
const res = await send('dashboard-import', {
|
||||||
|
filepath,
|
||||||
|
dashboardPageId: dashboard.id,
|
||||||
|
});
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
|
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
@@ -346,6 +361,23 @@ export function Overview() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCopyWidget = (widgetId: string, targetDashboardId: string) => {
|
||||||
|
send('dashboard-copy-widget', {
|
||||||
|
widgetId,
|
||||||
|
targetDashboardPageId: targetDashboardId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteDashboard = async (id: string) => {
|
||||||
|
await send('dashboard-delete', id);
|
||||||
|
|
||||||
|
const next_dashboard = dashboard_pages.find(d => d.id !== id);
|
||||||
|
// NOTE: This should hold since invariant dashboard_pages > 1
|
||||||
|
if (next_dashboard) {
|
||||||
|
navigate(`/reports/${next_dashboard.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const accounts = useAccounts();
|
const accounts = useAccounts();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -356,26 +388,69 @@ export function Overview() {
|
|||||||
<Page
|
<Page
|
||||||
header={
|
header={
|
||||||
isNarrowWidth ? (
|
isNarrowWidth ? (
|
||||||
<MobilePageHeader title={t('Reports')} />
|
<View>
|
||||||
|
<MobilePageHeader
|
||||||
|
title={
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Reports</Trans>: {dashboard.name}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
padding: '5px',
|
||||||
|
borderBottom: '1px solid ' + theme.pillBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DashboardSelector
|
||||||
|
dashboards={dashboard_pages}
|
||||||
|
currentDashboard={dashboard}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginRight: 15,
|
marginRight: 15,
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PageHeader title={t('Reports')} />
|
<DashboardHeader dashboard={dashboard} />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 5,
|
gap: 5,
|
||||||
|
alignItems: 'stretch',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentBreakpoint === 'desktop' && (
|
{currentBreakpoint === 'desktop' && (
|
||||||
<>
|
<>
|
||||||
|
{/* Dashboard Selector */}
|
||||||
|
<DashboardSelector
|
||||||
|
dashboards={dashboard_pages}
|
||||||
|
currentDashboard={dashboard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 'auto',
|
||||||
|
borderLeft: `1.5px solid ${theme.pillBorderDark}`,
|
||||||
|
borderRadius: 0.75,
|
||||||
|
marginLeft: 7,
|
||||||
|
marginRight: 7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Button variant="primary" isDisabled={isImporting}>
|
<Button variant="primary" isDisabled={isImporting}>
|
||||||
<Trans>Add new widget</Trans>
|
<Trans>Add new widget</Trans>
|
||||||
@@ -471,6 +546,7 @@ export function Overview() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
|
{/* The Editing Button */}
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isImporting}
|
isDisabled={isImporting}
|
||||||
@@ -487,6 +563,7 @@ export function Overview() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The Menu */}
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Button variant="bare" aria-label={t('Menu')}>
|
<Button variant="bare" aria-label={t('Menu')}>
|
||||||
<SvgDotsHorizontalTriple
|
<SvgDotsHorizontalTriple
|
||||||
@@ -510,6 +587,9 @@ export function Overview() {
|
|||||||
case 'import':
|
case 'import':
|
||||||
onImport();
|
onImport();
|
||||||
break;
|
break;
|
||||||
|
case 'delete':
|
||||||
|
onDeleteDashboard(dashboard.id);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unrecognized menu option: ${item}`,
|
`Unrecognized menu option: ${item}`,
|
||||||
@@ -533,6 +613,13 @@ export function Overview() {
|
|||||||
text: t('Export'),
|
text: t('Export'),
|
||||||
disabled: isImporting,
|
disabled: isImporting,
|
||||||
},
|
},
|
||||||
|
Menu.line,
|
||||||
|
{
|
||||||
|
name: 'delete',
|
||||||
|
text: t('Delete dashboard'),
|
||||||
|
disabled:
|
||||||
|
isImporting || dashboard_pages.length <= 1,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -577,6 +664,9 @@ export function Overview() {
|
|||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : item.type === 'crossover-card' &&
|
) : item.type === 'crossover-card' &&
|
||||||
crossoverReportEnabled ? (
|
crossoverReportEnabled ? (
|
||||||
@@ -587,6 +677,9 @@ export function Overview() {
|
|||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : item.type === 'cash-flow-card' ? (
|
) : item.type === 'cash-flow-card' ? (
|
||||||
<CashFlowCard
|
<CashFlowCard
|
||||||
@@ -595,6 +688,9 @@ export function Overview() {
|
|||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : item.type === 'spending-card' ? (
|
) : item.type === 'spending-card' ? (
|
||||||
<SpendingCard
|
<SpendingCard
|
||||||
@@ -603,6 +699,9 @@ export function Overview() {
|
|||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : item.type === 'markdown-card' ? (
|
) : item.type === 'markdown-card' ? (
|
||||||
<MarkdownCard
|
<MarkdownCard
|
||||||
@@ -610,12 +709,18 @@ export function Overview() {
|
|||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : item.type === 'custom-report' ? (
|
) : item.type === 'custom-report' ? (
|
||||||
<CustomReportListCards
|
<CustomReportListCards
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
report={customReportMap.get(item.meta.id)}
|
report={customReportMap.get(item.meta.id)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : item.type === 'summary-card' ? (
|
) : item.type === 'summary-card' ? (
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
@@ -624,6 +729,9 @@ export function Overview() {
|
|||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : item.type === 'calendar-card' ? (
|
) : item.type === 'calendar-card' ? (
|
||||||
<CalendarCard
|
<CalendarCard
|
||||||
@@ -633,6 +741,9 @@ export function Overview() {
|
|||||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : item.type === 'formula-card' && formulaMode ? (
|
) : item.type === 'formula-card' && formulaMode ? (
|
||||||
<FormulaCard
|
<FormulaCard
|
||||||
@@ -641,6 +752,9 @@ export function Overview() {
|
|||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||||
onRemove={() => onRemoveWidget(item.i)}
|
onRemove={() => onRemoveWidget(item.i)}
|
||||||
|
onCopy={targetDashboardId =>
|
||||||
|
onCopyWidget(item.i, targetDashboardId)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ type ReportCardProps = {
|
|||||||
disableClick?: boolean;
|
disableClick?: boolean;
|
||||||
to?: string;
|
to?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
menuItems?: ComponentProps<typeof Menu>['items'];
|
menuItems?: ComponentProps<typeof Menu<string>>['items'];
|
||||||
onMenuSelect?: ComponentProps<typeof Menu>['onMenuSelect'];
|
onMenuSelect?: ComponentProps<typeof Menu<string>>['onMenuSelect'];
|
||||||
size?: number;
|
size?: number;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Route, Routes } from 'react-router';
|
import { Route, Routes } from 'react-router';
|
||||||
|
|
||||||
import { Overview } from './Overview';
|
|
||||||
import { Calendar } from './reports/Calendar';
|
import { Calendar } from './reports/Calendar';
|
||||||
import { CashFlow } from './reports/CashFlow';
|
import { CashFlow } from './reports/CashFlow';
|
||||||
import { Crossover } from './reports/Crossover';
|
import { Crossover } from './reports/Crossover';
|
||||||
@@ -10,6 +9,7 @@ import { Formula } from './reports/Formula';
|
|||||||
import { NetWorth } from './reports/NetWorth';
|
import { NetWorth } from './reports/NetWorth';
|
||||||
import { Spending } from './reports/Spending';
|
import { Spending } from './reports/Spending';
|
||||||
import { Summary } from './reports/Summary';
|
import { Summary } from './reports/Summary';
|
||||||
|
import { ReportsDashboardRouter } from './ReportsDashboardRouter';
|
||||||
|
|
||||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||||
|
|
||||||
@@ -18,7 +18,8 @@ export function ReportRouter() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Overview />} />
|
<Route path="/" element={<ReportsDashboardRouter />} />
|
||||||
|
<Route path="/:dashboardId" element={<ReportsDashboardRouter />} />
|
||||||
<Route path="/net-worth" element={<NetWorth />} />
|
<Route path="/net-worth" element={<NetWorth />} />
|
||||||
<Route path="/net-worth/:id" element={<NetWorth />} />
|
<Route path="/net-worth/:id" element={<NetWorth />} />
|
||||||
{crossoverReportEnabled && (
|
{crossoverReportEnabled && (
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from 'loot-core/types/models';
|
} from 'loot-core/types/models';
|
||||||
|
|
||||||
import { GraphButton } from './GraphButton';
|
import { GraphButton } from './GraphButton';
|
||||||
import { SaveReport } from './SaveReport';
|
import { SaveReportWrapper } from './SaveReport';
|
||||||
import { setSessionReport } from './setSessionReport';
|
import { setSessionReport } from './setSessionReport';
|
||||||
import { SnapshotButton } from './SnapshotButton';
|
import { SnapshotButton } from './SnapshotButton';
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ type ReportTopbarProps = {
|
|||||||
viewLabels: boolean;
|
viewLabels: boolean;
|
||||||
onApplyFilter: (newFilter: RuleConditionEntity) => void;
|
onApplyFilter: (newFilter: RuleConditionEntity) => void;
|
||||||
onChangeViews: (viewType: string) => void;
|
onChangeViews: (viewType: string) => void;
|
||||||
onReportChange: ComponentProps<typeof SaveReport>['onReportChange'];
|
onReportChange: ComponentProps<typeof SaveReportWrapper>['onReportChange'];
|
||||||
isItemDisabled: (type: string) => boolean;
|
isItemDisabled: (type: string) => boolean;
|
||||||
defaultItems: (item: string) => void;
|
defaultItems: (item: string) => void;
|
||||||
};
|
};
|
||||||
@@ -243,7 +243,7 @@ export function ReportTopbar({
|
|||||||
}}
|
}}
|
||||||
exclude={[]}
|
exclude={[]}
|
||||||
/>
|
/>
|
||||||
<SaveReport
|
<SaveReportWrapper
|
||||||
customReportItems={customReportItems}
|
customReportItems={customReportItems}
|
||||||
report={report}
|
report={report}
|
||||||
savedStatus={savedStatus}
|
savedStatus={savedStatus}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { Block } from '@actual-app/components/block';
|
||||||
|
import { View } from '@actual-app/components/view';
|
||||||
|
|
||||||
|
import { LoadingIndicator } from './LoadingIndicator';
|
||||||
|
import { Overview } from './Overview';
|
||||||
|
|
||||||
|
import { useDashboardPages } from '@desktop-client/hooks/useDashboard';
|
||||||
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||||
|
|
||||||
|
export function ReportsDashboardRouter() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { dashboardId } = useParams<{ dashboardId?: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: dashboard_pages, isLoading } = useDashboardPages();
|
||||||
|
|
||||||
|
// Redirect to first dashboard if no dashboardId in URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dashboardId && !isLoading && dashboard_pages.length > 0) {
|
||||||
|
navigate(`/reports/${dashboard_pages[0].id}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [dashboardId, isLoading, dashboard_pages, navigate]);
|
||||||
|
|
||||||
|
// Show loading while we're fetching dashboards or redirecting
|
||||||
|
if (isLoading || (!dashboardId && dashboard_pages.length > 0)) {
|
||||||
|
return <LoadingIndicator message={t('Loading dashboards...')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a dashboardId, render Overview with it
|
||||||
|
if (dashboardId) {
|
||||||
|
const dashboard = dashboard_pages.find(d => d.id === dashboardId);
|
||||||
|
if (dashboard) {
|
||||||
|
return <Overview dashboard={dashboard} />;
|
||||||
|
} else {
|
||||||
|
// Invalid dashboardId - show error
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
gap: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Block style={{ marginBottom: 20, fontSize: 18 }}>
|
||||||
|
<Trans>Dashboard not found</Trans>
|
||||||
|
</Block>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No dashboards exist (NOTE: This should not happen invariant is we always should have at least 1 dashboard)
|
||||||
|
return <LoadingIndicator message={t('No dashboards available')} />;
|
||||||
|
}
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
import React, { createRef, useRef, useState } from 'react';
|
import React, { createRef, useRef, useState } from 'react';
|
||||||
import { Trans } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button } from '@actual-app/components/button';
|
import { Button } from '@actual-app/components/button';
|
||||||
import { SvgExpandArrow } from '@actual-app/components/icons/v0';
|
import { SvgExpandArrow } from '@actual-app/components/icons/v0';
|
||||||
import { Popover } from '@actual-app/components/popover';
|
import { Popover } from '@actual-app/components/popover';
|
||||||
|
import { Select } from '@actual-app/components/select';
|
||||||
|
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||||
import { Text } from '@actual-app/components/text';
|
import { Text } from '@actual-app/components/text';
|
||||||
import { View } from '@actual-app/components/view';
|
import { View } from '@actual-app/components/view';
|
||||||
|
|
||||||
import { send, sendCatch } from 'loot-core/platform/client/fetch';
|
import { send, sendCatch } from 'loot-core/platform/client/fetch';
|
||||||
import { type CustomReportEntity } from 'loot-core/types/models';
|
import {
|
||||||
|
type CustomReportEntity,
|
||||||
|
type DashboardEntity,
|
||||||
|
} from 'loot-core/types/models';
|
||||||
|
|
||||||
|
import { LoadingIndicator } from './LoadingIndicator';
|
||||||
import { SaveReportChoose } from './SaveReportChoose';
|
import { SaveReportChoose } from './SaveReportChoose';
|
||||||
import { SaveReportDelete } from './SaveReportDelete';
|
import { SaveReportDelete } from './SaveReportDelete';
|
||||||
import { SaveReportMenu } from './SaveReportMenu';
|
import { SaveReportMenu } from './SaveReportMenu';
|
||||||
import { SaveReportName } from './SaveReportName';
|
import { SaveReportName } from './SaveReportName';
|
||||||
|
|
||||||
|
import { FormField, FormLabel } from '@desktop-client/components/forms';
|
||||||
|
import { useDashboardPages } from '@desktop-client/hooks/useDashboard';
|
||||||
import { useReports } from '@desktop-client/hooks/useReports';
|
import { useReports } from '@desktop-client/hooks/useReports';
|
||||||
|
|
||||||
type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
|
type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
|
||||||
@@ -45,13 +53,28 @@ type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
|
|||||||
savedReport?: CustomReportEntity;
|
savedReport?: CustomReportEntity;
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
|
dashboardPages: readonly DashboardEntity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function SaveReportWrapper<
|
||||||
|
T extends CustomReportEntity = CustomReportEntity,
|
||||||
|
>(props: Omit<SaveReportProps<T>, 'dashboardPages'>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useDashboardPages();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingIndicator message={t('Loading dashboards...')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SaveReport {...props} dashboardPages={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveReport({
|
export function SaveReport({
|
||||||
customReportItems,
|
customReportItems,
|
||||||
report,
|
report,
|
||||||
savedStatus,
|
savedStatus,
|
||||||
onReportChange,
|
onReportChange,
|
||||||
|
dashboardPages,
|
||||||
}: SaveReportProps) {
|
}: SaveReportProps) {
|
||||||
const { data: listReports } = useReports();
|
const { data: listReports } = useReports();
|
||||||
const triggerRef = useRef(null);
|
const triggerRef = useRef(null);
|
||||||
@@ -63,6 +86,10 @@ export function SaveReport({
|
|||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [newName, setNewName] = useState(report.name ?? '');
|
const [newName, setNewName] = useState(report.name ?? '');
|
||||||
const inputRef = createRef<HTMLInputElement>();
|
const inputRef = createRef<HTMLInputElement>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [saveDashboardId, setSaveDashboardId] = useState<string | null>(
|
||||||
|
dashboardPages.length > 0 ? dashboardPages[0].id : null,
|
||||||
|
);
|
||||||
|
|
||||||
async function onApply(cond: string) {
|
async function onApply(cond: string) {
|
||||||
const chooseSavedReport = listReports.find(r => cond === r.id);
|
const chooseSavedReport = listReports.find(r => cond === r.id);
|
||||||
@@ -82,6 +109,11 @@ export function SaveReport({
|
|||||||
name: newName,
|
name: newName,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!saveDashboardId) {
|
||||||
|
setErr(t('Please select a dashboard to save the report'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await sendCatch('report/create', newSavedReport);
|
const response = await sendCatch('report/create', newSavedReport);
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
@@ -89,13 +121,12 @@ export function SaveReport({
|
|||||||
setNameMenuOpen(true);
|
setNameMenuOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to dashboard
|
|
||||||
await send('dashboard-add-widget', {
|
await send('dashboard-add-widget', {
|
||||||
type: 'custom-report',
|
type: 'custom-report',
|
||||||
width: 4,
|
width: 4,
|
||||||
height: 2,
|
height: 2,
|
||||||
meta: { id: response.data },
|
meta: { id: response.data },
|
||||||
|
dashboard_page_id: saveDashboardId,
|
||||||
});
|
});
|
||||||
|
|
||||||
setNameMenuOpen(false);
|
setNameMenuOpen(false);
|
||||||
@@ -202,7 +233,11 @@ export function SaveReport({
|
|||||||
>
|
>
|
||||||
{!report.id ? <Trans>Unsaved report</Trans> : report.name}
|
{!report.id ? <Trans>Unsaved report</Trans> : report.name}
|
||||||
</Text>
|
</Text>
|
||||||
{savedStatus === 'modified' && <Text>(modified) </Text>}
|
{savedStatus === 'modified' && (
|
||||||
|
<Text>
|
||||||
|
<Trans>(modified)</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<SvgExpandArrow width={8} height={8} style={{ marginRight: 5 }} />
|
<SvgExpandArrow width={8} height={8} style={{ marginRight: 5 }} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -225,14 +260,44 @@ export function SaveReport({
|
|||||||
onOpenChange={() => setNameMenuOpen(false)}
|
onOpenChange={() => setNameMenuOpen(false)}
|
||||||
style={{ width: 325 }}
|
style={{ width: 325 }}
|
||||||
>
|
>
|
||||||
<SaveReportName
|
<View>
|
||||||
menuItem={menuItem}
|
<SaveReportName
|
||||||
name={newName}
|
menuItem={menuItem}
|
||||||
setName={setNewName}
|
name={newName}
|
||||||
inputRef={inputRef}
|
setName={setNewName}
|
||||||
onAddUpdate={onAddUpdate}
|
inputRef={inputRef}
|
||||||
err={err}
|
onAddUpdate={onAddUpdate}
|
||||||
/>
|
err={err}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{menuItem === 'save-report' && (
|
||||||
|
<View>
|
||||||
|
<SpaceBetween
|
||||||
|
style={{
|
||||||
|
padding: 15,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormField style={{ flex: 1 }}>
|
||||||
|
<FormLabel
|
||||||
|
title={t('Dashboard')}
|
||||||
|
htmlFor="dashboard-select"
|
||||||
|
style={{ userSelect: 'none' }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="dashboard-select"
|
||||||
|
value={saveDashboardId}
|
||||||
|
onChange={v => setSaveDashboardId(v)}
|
||||||
|
defaultLabel={t('None')}
|
||||||
|
options={dashboardPages.map(d => [d.id, d.name])}
|
||||||
|
style={{ marginTop: 10, width: 300 }}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</SpaceBetween>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
calendarSpreadsheet,
|
calendarSpreadsheet,
|
||||||
} from '@desktop-client/components/reports/spreadsheets/calendar-spreadsheet';
|
} from '@desktop-client/components/reports/spreadsheets/calendar-spreadsheet';
|
||||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import { type FormatType, useFormat } from '@desktop-client/hooks/useFormat';
|
import { type FormatType, useFormat } from '@desktop-client/hooks/useFormat';
|
||||||
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
||||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||||
@@ -53,6 +54,7 @@ type CalendarCardProps = {
|
|||||||
meta?: CalendarWidget['meta'];
|
meta?: CalendarWidget['meta'];
|
||||||
onMetaChange: (newMeta: CalendarWidget['meta']) => void;
|
onMetaChange: (newMeta: CalendarWidget['meta']) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
|
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ export function CalendarCard({
|
|||||||
meta = {},
|
meta = {},
|
||||||
onMetaChange,
|
onMetaChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
firstDayOfWeekIdx,
|
firstDayOfWeekIdx,
|
||||||
}: CalendarCardProps) {
|
}: CalendarCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -168,9 +171,13 @@ export function CalendarCard({
|
|||||||
return data?.calendarData.length;
|
return data?.calendarData.length;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportCard
|
<ReportCard
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
|
disableClick={nameMenuOpen}
|
||||||
to={`/reports/calendar/${widgetId}`}
|
to={`/reports/calendar/${widgetId}`}
|
||||||
menuItems={[
|
menuItems={[
|
||||||
{
|
{
|
||||||
@@ -181,8 +188,10 @@ export function CalendarCard({
|
|||||||
name: 'remove',
|
name: 'remove',
|
||||||
text: t('Remove'),
|
text: t('Remove'),
|
||||||
},
|
},
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
setNameMenuOpen(true);
|
setNameMenuOpen(true);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
|
|||||||
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||||
import { simpleCashFlow } from '@desktop-client/components/reports/spreadsheets/cash-flow-spreadsheet';
|
import { simpleCashFlow } from '@desktop-client/components/reports/spreadsheets/cash-flow-spreadsheet';
|
||||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||||
|
|
||||||
type CustomLabelProps = {
|
type CustomLabelProps = {
|
||||||
@@ -104,6 +105,7 @@ type CashFlowCardProps = {
|
|||||||
meta?: CashFlowWidget['meta'];
|
meta?: CashFlowWidget['meta'];
|
||||||
onMetaChange: (newMeta: CashFlowWidget['meta']) => void;
|
onMetaChange: (newMeta: CashFlowWidget['meta']) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CashFlowCard({
|
export function CashFlowCard({
|
||||||
@@ -112,12 +114,16 @@ export function CashFlowCard({
|
|||||||
meta = {},
|
meta = {},
|
||||||
onMetaChange,
|
onMetaChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: CashFlowCardProps) {
|
}: CashFlowCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const animationProps = useRechartsAnimation();
|
const animationProps = useRechartsAnimation();
|
||||||
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
||||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchLatestTransaction() {
|
async function fetchLatestTransaction() {
|
||||||
const latestTrans = await send('get-latest-transaction');
|
const latestTrans = await send('get-latest-transaction');
|
||||||
@@ -162,8 +168,10 @@ export function CashFlowCard({
|
|||||||
name: 'remove',
|
name: 'remove',
|
||||||
text: t('Remove'),
|
text: t('Remove'),
|
||||||
},
|
},
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
setNameMenuOpen(true);
|
setNameMenuOpen(true);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
|
|||||||
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||||
import { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet';
|
import { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet';
|
||||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||||
|
|
||||||
// Type for the return value of the recalculate function
|
// Type for the return value of the recalculate function
|
||||||
@@ -55,6 +56,7 @@ type CrossoverCardProps = {
|
|||||||
meta?: CrossoverWidget['meta'];
|
meta?: CrossoverWidget['meta'];
|
||||||
onMetaChange: (newMeta: CrossoverWidget['meta']) => void;
|
onMetaChange: (newMeta: CrossoverWidget['meta']) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CrossoverCard({
|
export function CrossoverCard({
|
||||||
@@ -64,12 +66,16 @@ export function CrossoverCard({
|
|||||||
meta = {},
|
meta = {},
|
||||||
onMetaChange,
|
onMetaChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: CrossoverCardProps) {
|
}: CrossoverCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isNarrowWidth } = useResponsive();
|
const { isNarrowWidth } = useResponsive();
|
||||||
|
|
||||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
|
|
||||||
// Calculate date range from meta or use default range
|
// Calculate date range from meta or use default range
|
||||||
const [start, setStart] = useState<string>('');
|
const [start, setStart] = useState<string>('');
|
||||||
const [end, setEnd] = useState<string>('');
|
const [end, setEnd] = useState<string>('');
|
||||||
@@ -206,8 +212,10 @@ export function CrossoverCard({
|
|||||||
menuItems={[
|
menuItems={[
|
||||||
{ name: 'rename', text: t('Rename') },
|
{ name: 'rename', text: t('Rename') },
|
||||||
{ name: 'remove', text: t('Remove') },
|
{ name: 'remove', text: t('Remove') },
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
setNameMenuOpen(true);
|
setNameMenuOpen(true);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { MissingReportCard } from './MissingReportCard';
|
|||||||
import { DateRange } from '@desktop-client/components/reports/DateRange';
|
import { DateRange } from '@desktop-client/components/reports/DateRange';
|
||||||
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
||||||
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
|
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import { calculateHasWarning } from '@desktop-client/components/reports/util';
|
import { calculateHasWarning } from '@desktop-client/components/reports/util';
|
||||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||||
@@ -30,12 +31,14 @@ type CustomReportListCardsProps = {
|
|||||||
isEditing?: boolean;
|
isEditing?: boolean;
|
||||||
report?: CustomReportEntity;
|
report?: CustomReportEntity;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CustomReportListCards({
|
export function CustomReportListCards({
|
||||||
isEditing,
|
isEditing,
|
||||||
report,
|
report,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: CustomReportListCardsProps) {
|
}: CustomReportListCardsProps) {
|
||||||
// It's possible for a dashboard to reference a non-existing
|
// It's possible for a dashboard to reference a non-existing
|
||||||
// custom report
|
// custom report
|
||||||
@@ -52,6 +55,7 @@ export function CustomReportListCards({
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
report={report}
|
report={report}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
|
onCopy={onCopy}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -60,6 +64,7 @@ function CustomReportListCardsInner({
|
|||||||
isEditing,
|
isEditing,
|
||||||
report,
|
report,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: Omit<CustomReportListCardsProps, 'report'> & {
|
}: Omit<CustomReportListCardsProps, 'report'> & {
|
||||||
report: CustomReportEntity;
|
report: CustomReportEntity;
|
||||||
}) {
|
}) {
|
||||||
@@ -71,6 +76,9 @@ function CustomReportListCardsInner({
|
|||||||
const [earliestTransaction, setEarliestTransaction] = useState('');
|
const [earliestTransaction, setEarliestTransaction] = useState('');
|
||||||
const [latestTransaction, setLatestTransaction] = useState('');
|
const [latestTransaction, setLatestTransaction] = useState('');
|
||||||
|
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
|
|
||||||
const payees = usePayees();
|
const payees = usePayees();
|
||||||
const accounts = useAccounts();
|
const accounts = useAccounts();
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
@@ -138,8 +146,10 @@ function CustomReportListCardsInner({
|
|||||||
name: 'remove',
|
name: 'remove',
|
||||||
text: t('Remove'),
|
text: t('Remove'),
|
||||||
},
|
},
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'remove':
|
case 'remove':
|
||||||
onRemove();
|
onRemove();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { type FormulaWidget } from 'loot-core/types/models';
|
|||||||
import { FormulaResult } from '@desktop-client/components/reports/FormulaResult';
|
import { FormulaResult } from '@desktop-client/components/reports/FormulaResult';
|
||||||
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
||||||
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
|
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import { useFormulaExecution } from '@desktop-client/hooks/useFormulaExecution';
|
import { useFormulaExecution } from '@desktop-client/hooks/useFormulaExecution';
|
||||||
import { useThemeColors } from '@desktop-client/hooks/useThemeColors';
|
import { useThemeColors } from '@desktop-client/hooks/useThemeColors';
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ type FormulaCardProps = {
|
|||||||
meta?: FormulaWidget['meta'];
|
meta?: FormulaWidget['meta'];
|
||||||
onMetaChange: (newMeta: FormulaWidget['meta']) => void;
|
onMetaChange: (newMeta: FormulaWidget['meta']) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FormulaCard({
|
export function FormulaCard({
|
||||||
@@ -25,9 +27,12 @@ export function FormulaCard({
|
|||||||
meta = {},
|
meta = {},
|
||||||
onMetaChange,
|
onMetaChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: FormulaCardProps) {
|
}: FormulaCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
const themeColors = useThemeColors();
|
const themeColors = useThemeColors();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -81,8 +86,10 @@ export function FormulaCard({
|
|||||||
name: 'remove',
|
name: 'remove',
|
||||||
text: t('Remove'),
|
text: t('Remove'),
|
||||||
},
|
},
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
setNameMenuOpen(true);
|
setNameMenuOpen(true);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { type MarkdownWidget } from 'loot-core/types/models';
|
|||||||
|
|
||||||
import { NON_DRAGGABLE_AREA_CLASS_NAME } from '@desktop-client/components/reports/constants';
|
import { NON_DRAGGABLE_AREA_CLASS_NAME } from '@desktop-client/components/reports/constants';
|
||||||
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import {
|
import {
|
||||||
remarkBreaks,
|
remarkBreaks,
|
||||||
sequentialNewlinesPlugin,
|
sequentialNewlinesPlugin,
|
||||||
@@ -38,6 +39,7 @@ type MarkdownCardProps = {
|
|||||||
meta: MarkdownWidget['meta'];
|
meta: MarkdownWidget['meta'];
|
||||||
onMetaChange: (newMeta: MarkdownWidget['meta']) => void;
|
onMetaChange: (newMeta: MarkdownWidget['meta']) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MarkdownCard({
|
export function MarkdownCard({
|
||||||
@@ -45,11 +47,15 @@ export function MarkdownCard({
|
|||||||
meta,
|
meta,
|
||||||
onMetaChange,
|
onMetaChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: MarkdownCardProps) {
|
}: MarkdownCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [isVisibleTextArea, setIsVisibleTextArea] = useState(false);
|
const [isVisibleTextArea, setIsVisibleTextArea] = useState(false);
|
||||||
|
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportCard
|
<ReportCard
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
@@ -81,8 +87,10 @@ export function MarkdownCard({
|
|||||||
name: 'remove',
|
name: 'remove',
|
||||||
text: t('Remove'),
|
text: t('Remove'),
|
||||||
},
|
},
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'text-left':
|
case 'text-left':
|
||||||
onMetaChange({
|
onMetaChange({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
|
|||||||
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||||
import { createSpreadsheet as netWorthSpreadsheet } from '@desktop-client/components/reports/spreadsheets/net-worth-spreadsheet';
|
import { createSpreadsheet as netWorthSpreadsheet } from '@desktop-client/components/reports/spreadsheets/net-worth-spreadsheet';
|
||||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||||
@@ -34,6 +35,7 @@ type NetWorthCardProps = {
|
|||||||
meta?: NetWorthWidget['meta'];
|
meta?: NetWorthWidget['meta'];
|
||||||
onMetaChange: (newMeta: NetWorthWidget['meta']) => void;
|
onMetaChange: (newMeta: NetWorthWidget['meta']) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NetWorthCard({
|
export function NetWorthCard({
|
||||||
@@ -43,6 +45,7 @@ export function NetWorthCard({
|
|||||||
meta = {},
|
meta = {},
|
||||||
onMetaChange,
|
onMetaChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: NetWorthCardProps) {
|
}: NetWorthCardProps) {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -55,6 +58,9 @@ export function NetWorthCard({
|
|||||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||||
const [isCardHovered, setIsCardHovered] = useState(false);
|
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||||
|
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchLatestTransaction() {
|
async function fetchLatestTransaction() {
|
||||||
const latestTrans = await send('get-latest-transaction');
|
const latestTrans = await send('get-latest-transaction');
|
||||||
@@ -114,8 +120,10 @@ export function NetWorthCard({
|
|||||||
name: 'remove',
|
name: 'remove',
|
||||||
text: t('Remove'),
|
text: t('Remove'),
|
||||||
},
|
},
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
setNameMenuOpen(true);
|
setNameMenuOpen(true);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
|
|||||||
import { calculateSpendingReportTimeRange } from '@desktop-client/components/reports/reportRanges';
|
import { calculateSpendingReportTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||||
import { createSpendingSpreadsheet } from '@desktop-client/components/reports/spreadsheets/spending-spreadsheet';
|
import { createSpendingSpreadsheet } from '@desktop-client/components/reports/spreadsheets/spending-spreadsheet';
|
||||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||||
|
|
||||||
type SpendingCardProps = {
|
type SpendingCardProps = {
|
||||||
@@ -26,6 +27,7 @@ type SpendingCardProps = {
|
|||||||
meta?: SpendingWidget['meta'];
|
meta?: SpendingWidget['meta'];
|
||||||
onMetaChange: (newMeta: SpendingWidget['meta']) => void;
|
onMetaChange: (newMeta: SpendingWidget['meta']) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SpendingCard({
|
export function SpendingCard({
|
||||||
@@ -34,12 +36,17 @@ export function SpendingCard({
|
|||||||
meta = {},
|
meta = {},
|
||||||
onMetaChange,
|
onMetaChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: SpendingCardProps) {
|
}: SpendingCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const format = useFormat();
|
const format = useFormat();
|
||||||
|
|
||||||
const [isCardHovered, setIsCardHovered] = useState(false);
|
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
|
|
||||||
const spendingReportMode = meta?.mode ?? 'single-month';
|
const spendingReportMode = meta?.mode ?? 'single-month';
|
||||||
|
|
||||||
const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
|
const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
|
||||||
@@ -83,8 +90,10 @@ export function SpendingCard({
|
|||||||
name: 'remove',
|
name: 'remove',
|
||||||
text: t('Remove'),
|
text: t('Remove'),
|
||||||
},
|
},
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
setNameMenuOpen(true);
|
setNameMenuOpen(true);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { calculateTimeRange } from '@desktop-client/components/reports/reportRan
|
|||||||
import { summarySpreadsheet } from '@desktop-client/components/reports/spreadsheets/summary-spreadsheet';
|
import { summarySpreadsheet } from '@desktop-client/components/reports/spreadsheets/summary-spreadsheet';
|
||||||
import { SummaryNumber } from '@desktop-client/components/reports/SummaryNumber';
|
import { SummaryNumber } from '@desktop-client/components/reports/SummaryNumber';
|
||||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||||
|
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||||
|
|
||||||
type SummaryCardProps = {
|
type SummaryCardProps = {
|
||||||
@@ -26,6 +27,7 @@ type SummaryCardProps = {
|
|||||||
meta?: SummaryWidget['meta'];
|
meta?: SummaryWidget['meta'];
|
||||||
onMetaChange: (newMeta: SummaryWidget['meta']) => void;
|
onMetaChange: (newMeta: SummaryWidget['meta']) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onCopy: (targetDashboardId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SummaryCard({
|
export function SummaryCard({
|
||||||
@@ -34,12 +36,16 @@ export function SummaryCard({
|
|||||||
meta = {},
|
meta = {},
|
||||||
onMetaChange,
|
onMetaChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onCopy,
|
||||||
}: SummaryCardProps) {
|
}: SummaryCardProps) {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
||||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||||
|
useWidgetCopyMenu(onCopy);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchLatestTransaction() {
|
async function fetchLatestTransaction() {
|
||||||
const latestTrans = await send('get-latest-transaction');
|
const latestTrans = await send('get-latest-transaction');
|
||||||
@@ -104,8 +110,10 @@ export function SummaryCard({
|
|||||||
name: 'remove',
|
name: 'remove',
|
||||||
text: t('Remove'),
|
text: t('Remove'),
|
||||||
},
|
},
|
||||||
|
...copyMenuItems,
|
||||||
]}
|
]}
|
||||||
onMenuSelect={item => {
|
onMenuSelect={item => {
|
||||||
|
if (handleCopyMenuSelect(item)) return;
|
||||||
switch (item) {
|
switch (item) {
|
||||||
case 'rename':
|
case 'rename':
|
||||||
setNameMenuOpen(true);
|
setNameMenuOpen(true);
|
||||||
@@ -114,8 +122,7 @@ export function SummaryCard({
|
|||||||
onRemove();
|
onRemove();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Unrecognized menu selection: ${item}`);
|
throw new Error(`Unrecognized menu selection: ${item}`);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { type ComponentProps } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { type Menu } from '@actual-app/components/menu';
|
||||||
|
|
||||||
|
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||||
|
import { useDispatch } from '@desktop-client/redux';
|
||||||
|
|
||||||
|
type WidgetCopyMenuResult = {
|
||||||
|
/** Menu items to add to the card's menu */
|
||||||
|
menuItems: ComponentProps<typeof Menu<string>>['items'];
|
||||||
|
/** Handler for menu selection - call this from onMenuSelect */
|
||||||
|
handleMenuSelect: (item: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWidgetCopyMenu(
|
||||||
|
onCopy: (targetDashboardId: string) => void,
|
||||||
|
): WidgetCopyMenuResult {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const menuItems: ComponentProps<typeof Menu<string>>['items'] = [
|
||||||
|
{
|
||||||
|
name: 'copy-to-dashboard',
|
||||||
|
text: t('Copy to dashboard'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenuSelect = (item: string): boolean => {
|
||||||
|
switch (item) {
|
||||||
|
case 'copy-to-dashboard':
|
||||||
|
dispatch(
|
||||||
|
pushModal({
|
||||||
|
modal: {
|
||||||
|
name: 'copy-widget-to-dashboard',
|
||||||
|
options: {
|
||||||
|
onSelect: targetDashboardId => {
|
||||||
|
onCopy(targetDashboardId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
menuItems,
|
||||||
|
handleMenuSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { q } from 'loot-core/shared/query';
|
import { q } from 'loot-core/shared/query';
|
||||||
import { type Widget } from 'loot-core/types/models';
|
import { type Widget, type DashboardEntity } from 'loot-core/types/models';
|
||||||
|
|
||||||
import { useQuery } from './useQuery';
|
import { useQuery } from './useQuery';
|
||||||
|
|
||||||
export function useDashboard() {
|
export function useDashboard(dashboardPageId: string) {
|
||||||
const { data: queryData, isLoading } = useQuery<Widget>(
|
const { data: queryData, isLoading } = useQuery<Widget>(() => {
|
||||||
() => q('dashboard').select('*'),
|
return q('dashboard')
|
||||||
|
.filter({ dashboard_page_id: dashboardPageId })
|
||||||
|
.select('*');
|
||||||
|
}, [dashboardPageId]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
isLoading,
|
||||||
|
data: queryData || [],
|
||||||
|
}),
|
||||||
|
[isLoading, queryData],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardPages() {
|
||||||
|
const { data: queryData, isLoading } = useQuery<DashboardEntity>(
|
||||||
|
() => q('dashboard_pages').select('*'),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -518,6 +518,12 @@ export type Modal =
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'copy-widget-to-dashboard';
|
||||||
|
options: {
|
||||||
|
onSelect: (dashboardId: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'edit-user';
|
name: 'edit-user';
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export default async function runMigration(db) {
|
||||||
|
db.transaction(() => {
|
||||||
|
// 1. Create dashboards table
|
||||||
|
db.execQuery(`
|
||||||
|
CREATE TABLE dashboard_pages
|
||||||
|
(id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
tombstone INTEGER DEFAULT 0);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. Add dashboard_page_id to dashboard (widgets) table
|
||||||
|
db.execQuery(`
|
||||||
|
ALTER TABLE dashboard ADD COLUMN dashboard_page_id TEXT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. Create a default dashboard
|
||||||
|
const defaultDashboardId = uuidv4();
|
||||||
|
db.runQuery(`INSERT INTO dashboard_pages (id, name) VALUES (?, ?)`, [
|
||||||
|
defaultDashboardId,
|
||||||
|
'Main',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. Migrate existing widgets to the default dashboard
|
||||||
|
db.runQuery(`UPDATE dashboard SET dashboard_page_id = ?`, [
|
||||||
|
defaultDashboardId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -182,8 +182,14 @@ export const schema = {
|
|||||||
goal: f('integer'),
|
goal: f('integer'),
|
||||||
long_goal: f('integer'),
|
long_goal: f('integer'),
|
||||||
},
|
},
|
||||||
|
dashboard_pages: {
|
||||||
|
id: f('id'),
|
||||||
|
name: f('string'),
|
||||||
|
tombstone: f('boolean'),
|
||||||
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
id: f('id'),
|
id: f('id'),
|
||||||
|
dashboard_page_id: f('id', { ref: 'dashboard_pages' }),
|
||||||
type: f('string', { required: true }),
|
type: f('string', { required: true }),
|
||||||
width: f('integer', { required: true }),
|
width: f('integer', { required: true }),
|
||||||
height: f('integer', { required: true }),
|
height: f('integer', { required: true }),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import isMatch from 'lodash/isMatch';
|
import isMatch from 'lodash/isMatch';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { captureException } from '../../platform/exceptions';
|
import { captureException } from '../../platform/exceptions';
|
||||||
import * as fs from '../../platform/server/fs';
|
import * as fs from '../../platform/server/fs';
|
||||||
@@ -27,6 +28,20 @@ function isExportedCustomReportWidget(
|
|||||||
return widget.type === 'custom-report';
|
return widget.type === 'custom-report';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWidgetType(type: string): type is Widget['type'] {
|
||||||
|
return [
|
||||||
|
'net-worth-card',
|
||||||
|
'cash-flow-card',
|
||||||
|
'spending-card',
|
||||||
|
'crossover-card',
|
||||||
|
'markdown-card',
|
||||||
|
'summary-card',
|
||||||
|
'calendar-card',
|
||||||
|
'formula-card',
|
||||||
|
'custom-report',
|
||||||
|
].includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
const exportModel = {
|
const exportModel = {
|
||||||
validate(dashboard: ExportImportDashboard) {
|
validate(dashboard: ExportImportDashboard) {
|
||||||
requiredFields('Dashboard', dashboard, ['version', 'widgets']);
|
requiredFields('Dashboard', dashboard, ['version', 'widgets']);
|
||||||
@@ -71,19 +86,7 @@ const exportModel = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!isWidgetType(widget.type)) {
|
||||||
![
|
|
||||||
'net-worth-card',
|
|
||||||
'cash-flow-card',
|
|
||||||
'spending-card',
|
|
||||||
'crossover-card',
|
|
||||||
'custom-report',
|
|
||||||
'markdown-card',
|
|
||||||
'summary-card',
|
|
||||||
'calendar-card',
|
|
||||||
'formula-card',
|
|
||||||
].includes(widget.type)
|
|
||||||
) {
|
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`Invalid widget.${idx}.type value ${widget.type}.`,
|
`Invalid widget.${idx}.type value ${widget.type}.`,
|
||||||
);
|
);
|
||||||
@@ -96,6 +99,44 @@ const exportModel = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function getDashboardPages() {
|
||||||
|
return db.all('SELECT * FROM dashboard_pages WHERE tombstone = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDashboardPage({ name }: { name: string }) {
|
||||||
|
const id = uuidv4();
|
||||||
|
await db.insertWithSchema('dashboard_pages', { id, name });
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDashboardPage(id: string) {
|
||||||
|
const res = await db.first<{ c: number }>(
|
||||||
|
'SELECT count(*) as c FROM dashboard_pages WHERE tombstone = 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((res?.c ?? 0) <= 1) {
|
||||||
|
throw new Error('Cannot delete the last dashboard page');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleting_widgets = await db.all<Pick<db.DbDashboard, 'id'>>(
|
||||||
|
'SELECT id FROM dashboard WHERE dashboard_page_id = ? AND tombstone = 0',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
await batchMessages(async () => {
|
||||||
|
await db.delete_('dashboard_pages', id);
|
||||||
|
// Tombstone all widgets for this dashboard
|
||||||
|
await Promise.all(
|
||||||
|
deleting_widgets.map(({ id }) => db.delete_('dashboard', id)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameDashboardPage({ id, name }: { id: string; name: string }) {
|
||||||
|
await db.updateWithSchema('dashboard_pages', { id, name });
|
||||||
|
}
|
||||||
|
|
||||||
async function updateDashboard(
|
async function updateDashboard(
|
||||||
widgets: EverythingButIdOptional<Omit<Widget, 'tombstone'>>[],
|
widgets: EverythingButIdOptional<Omit<Widget, 'tombstone'>>[],
|
||||||
) {
|
) {
|
||||||
@@ -122,15 +163,21 @@ async function updateDashboardWidget(
|
|||||||
await db.updateWithSchema('dashboard', widget);
|
await db.updateWithSchema('dashboard', widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetDashboard() {
|
async function resetDashboard(id: string) {
|
||||||
await batchMessages(async () => {
|
await batchMessages(async () => {
|
||||||
|
const widgets = await db.selectWithSchema(
|
||||||
|
'dashboard',
|
||||||
|
'SELECT id FROM dashboard WHERE dashboard_page_id = ? AND tombstone = 0',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// Delete all widgets
|
// Delete all widgets for this dashboard
|
||||||
db.deleteAll('dashboard'),
|
...widgets.map(({ id }) => db.delete_('dashboard', id)),
|
||||||
|
|
||||||
// Insert the default state
|
// Insert the default state
|
||||||
...DEFAULT_DASHBOARD_STATE.map(widget =>
|
...DEFAULT_DASHBOARD_STATE.map(widget =>
|
||||||
db.insertWithSchema('dashboard', widget),
|
db.insertWithSchema('dashboard', { ...widget, dashboard_page_id: id }),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -138,7 +185,7 @@ async function resetDashboard() {
|
|||||||
|
|
||||||
async function addDashboardWidget(
|
async function addDashboardWidget(
|
||||||
widget: Omit<Widget, 'id' | 'x' | 'y' | 'tombstone'> &
|
widget: Omit<Widget, 'id' | 'x' | 'y' | 'tombstone'> &
|
||||||
Partial<Pick<Widget, 'x' | 'y'>>,
|
Partial<Pick<Widget, 'x' | 'y'>> & { dashboard_page_id: string },
|
||||||
) {
|
) {
|
||||||
// If no x & y was provided - calculate it dynamically
|
// If no x & y was provided - calculate it dynamically
|
||||||
// The new widget should be the very last one in the list of all widgets
|
// The new widget should be the very last one in the list of all widgets
|
||||||
@@ -146,7 +193,8 @@ async function addDashboardWidget(
|
|||||||
const data = await db.first<
|
const data = await db.first<
|
||||||
Pick<db.DbDashboard, 'x' | 'y' | 'width' | 'height'>
|
Pick<db.DbDashboard, 'x' | 'y' | 'width' | 'height'>
|
||||||
>(
|
>(
|
||||||
'SELECT x, y, width, height FROM dashboard WHERE tombstone = 0 ORDER BY y DESC, x DESC',
|
'SELECT x, y, width, height FROM dashboard WHERE dashboard_page_id = ? AND tombstone = 0 ORDER BY y DESC, x DESC',
|
||||||
|
[widget.dashboard_page_id],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -159,14 +207,59 @@ async function addDashboardWidget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insertWithSchema('dashboard', widget);
|
const { dashboard_page_id, ...widgetWithoutDashboardPageId } = widget;
|
||||||
|
|
||||||
|
await db.insertWithSchema('dashboard', {
|
||||||
|
...widgetWithoutDashboardPageId,
|
||||||
|
dashboard_page_id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeDashboardWidget(widgetId: string) {
|
async function removeDashboardWidget(widgetId: string) {
|
||||||
await db.delete_('dashboard', widgetId);
|
await db.delete_('dashboard', widgetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importDashboard({ filepath }: { filepath: string }) {
|
async function copyDashboardWidget({
|
||||||
|
widgetId,
|
||||||
|
targetDashboardPageId,
|
||||||
|
}: {
|
||||||
|
widgetId: string;
|
||||||
|
targetDashboardPageId: string;
|
||||||
|
}) {
|
||||||
|
// Get the widget to copy
|
||||||
|
const widget = await db.first<db.DbDashboard>(
|
||||||
|
'SELECT * FROM dashboard WHERE id = ? AND tombstone = 0',
|
||||||
|
[widgetId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
throw new Error(`Widget not found: ${widgetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await batchMessages(async () => {
|
||||||
|
// Insert the widget to target dashboard
|
||||||
|
if (isWidgetType(widget.type)) {
|
||||||
|
const newWidget = {
|
||||||
|
type: widget.type,
|
||||||
|
width: widget.width,
|
||||||
|
height: widget.height,
|
||||||
|
meta: widget.meta ? JSON.parse(widget.meta) : {},
|
||||||
|
dashboard_page_id: targetDashboardPageId,
|
||||||
|
};
|
||||||
|
await addDashboardWidget(newWidget);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported widget type: ${widget.type}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importDashboard({
|
||||||
|
filepath,
|
||||||
|
dashboardPageId,
|
||||||
|
}: {
|
||||||
|
filepath: string;
|
||||||
|
dashboardPageId: string;
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
if (!(await fs.exists(filepath))) {
|
if (!(await fs.exists(filepath))) {
|
||||||
throw new Error(`File not found at the provided path: ${filepath}`);
|
throw new Error(`File not found at the provided path: ${filepath}`);
|
||||||
@@ -182,10 +275,16 @@ async function importDashboard({ filepath }: { filepath: string }) {
|
|||||||
);
|
);
|
||||||
const customReportIdSet = new Set(customReportIds.map(({ id }) => id));
|
const customReportIdSet = new Set(customReportIds.map(({ id }) => id));
|
||||||
|
|
||||||
|
const existingWidgets = await db.selectWithSchema(
|
||||||
|
'dashboard',
|
||||||
|
'SELECT id FROM dashboard WHERE dashboard_page_id = ? AND tombstone = 0',
|
||||||
|
[dashboardPageId],
|
||||||
|
);
|
||||||
|
|
||||||
await batchMessages(async () => {
|
await batchMessages(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// Delete all widgets
|
// Delete all widgets
|
||||||
db.deleteAll('dashboard'),
|
...existingWidgets.map(({ id }) => db.delete_('dashboard', id)),
|
||||||
|
|
||||||
// Insert new widgets
|
// Insert new widgets
|
||||||
...parsedContent.widgets.map(widget =>
|
...parsedContent.widgets.map(widget =>
|
||||||
@@ -195,6 +294,7 @@ async function importDashboard({ filepath }: { filepath: string }) {
|
|||||||
height: widget.height,
|
height: widget.height,
|
||||||
x: widget.x,
|
x: widget.x,
|
||||||
y: widget.y,
|
y: widget.y,
|
||||||
|
dashboard_page_id: dashboardPageId,
|
||||||
meta: isExportedCustomReportWidget(widget)
|
meta: isExportedCustomReportWidget(widget)
|
||||||
? { id: widget.meta.id }
|
? { id: widget.meta.id }
|
||||||
: widget.meta,
|
: widget.meta,
|
||||||
@@ -246,19 +346,29 @@ async function importDashboard({ filepath }: { filepath: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DashboardHandlers = {
|
export type DashboardHandlers = {
|
||||||
|
'dashboard_pages-get': typeof getDashboardPages;
|
||||||
|
'dashboard-create': typeof createDashboardPage;
|
||||||
|
'dashboard-delete': typeof deleteDashboardPage;
|
||||||
|
'dashboard-rename': typeof renameDashboardPage;
|
||||||
'dashboard-update': typeof updateDashboard;
|
'dashboard-update': typeof updateDashboard;
|
||||||
'dashboard-update-widget': typeof updateDashboardWidget;
|
'dashboard-update-widget': typeof updateDashboardWidget;
|
||||||
'dashboard-reset': typeof resetDashboard;
|
'dashboard-reset': typeof resetDashboard;
|
||||||
'dashboard-add-widget': typeof addDashboardWidget;
|
'dashboard-add-widget': typeof addDashboardWidget;
|
||||||
'dashboard-remove-widget': typeof removeDashboardWidget;
|
'dashboard-remove-widget': typeof removeDashboardWidget;
|
||||||
|
'dashboard-copy-widget': typeof copyDashboardWidget;
|
||||||
'dashboard-import': typeof importDashboard;
|
'dashboard-import': typeof importDashboard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const app = createApp<DashboardHandlers>();
|
export const app = createApp<DashboardHandlers>();
|
||||||
|
|
||||||
|
app.method('dashboard_pages-get', getDashboardPages);
|
||||||
|
app.method('dashboard-create', mutator(undoable(createDashboardPage)));
|
||||||
|
app.method('dashboard-delete', mutator(undoable(deleteDashboardPage)));
|
||||||
|
app.method('dashboard-rename', mutator(undoable(renameDashboardPage)));
|
||||||
app.method('dashboard-update', mutator(undoable(updateDashboard)));
|
app.method('dashboard-update', mutator(undoable(updateDashboard)));
|
||||||
app.method('dashboard-update-widget', mutator(undoable(updateDashboardWidget)));
|
app.method('dashboard-update-widget', mutator(undoable(updateDashboardWidget)));
|
||||||
app.method('dashboard-reset', mutator(undoable(resetDashboard)));
|
app.method('dashboard-reset', mutator(undoable(resetDashboard)));
|
||||||
app.method('dashboard-add-widget', mutator(undoable(addDashboardWidget)));
|
app.method('dashboard-add-widget', mutator(undoable(addDashboardWidget)));
|
||||||
app.method('dashboard-remove-widget', mutator(undoable(removeDashboardWidget)));
|
app.method('dashboard-remove-widget', mutator(undoable(removeDashboardWidget)));
|
||||||
|
app.method('dashboard-copy-widget', mutator(undoable(copyDashboardWidget)));
|
||||||
app.method('dashboard-import', mutator(undoable(importDashboard)));
|
app.method('dashboard-import', mutator(undoable(importDashboard)));
|
||||||
|
|||||||
@@ -238,8 +238,15 @@ export type DbCustomReport = {
|
|||||||
tombstone: 1 | 0;
|
tombstone: 1 | 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DbDashboardPage = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tombstone: 1 | 0;
|
||||||
|
};
|
||||||
|
|
||||||
export type DbDashboard = {
|
export type DbDashboard = {
|
||||||
id: string;
|
id: string;
|
||||||
|
dashboard_page_id: string;
|
||||||
type: string;
|
type: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import m1632571489012 from '../../../migrations/1632571489012_remove_cache';
|
|||||||
import m1722717601000 from '../../../migrations/1722717601000_reports_move_selected_categories';
|
import m1722717601000 from '../../../migrations/1722717601000_reports_move_selected_categories';
|
||||||
import m1722804019000 from '../../../migrations/1722804019000_create_dashboard_table';
|
import m1722804019000 from '../../../migrations/1722804019000_create_dashboard_table';
|
||||||
import m1723665565000 from '../../../migrations/1723665565000_prefs';
|
import m1723665565000 from '../../../migrations/1723665565000_prefs';
|
||||||
|
import m1765518577215 from '../../../migrations/1765518577215_multiple_dashboards';
|
||||||
import * as fs from '../../platform/server/fs';
|
import * as fs from '../../platform/server/fs';
|
||||||
import { logger } from '../../platform/server/log';
|
import { logger } from '../../platform/server/log';
|
||||||
import * as sqlite from '../../platform/server/sqlite';
|
import * as sqlite from '../../platform/server/sqlite';
|
||||||
@@ -20,6 +21,7 @@ const javascriptMigrations = {
|
|||||||
1722717601000: m1722717601000,
|
1722717601000: m1722717601000,
|
||||||
1722804019000: m1722804019000,
|
1722804019000: m1722804019000,
|
||||||
1723665565000: m1723665565000,
|
1723665565000: m1723665565000,
|
||||||
|
1765518577215: m1765518577215,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function withMigrationsDir(
|
export async function withMigrationsDir(
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { type CustomReportEntity } from './reports';
|
import { type CustomReportEntity } from './reports';
|
||||||
import { type RuleConditionEntity } from './rule';
|
import { type RuleConditionEntity } from './rule';
|
||||||
|
|
||||||
|
export type DashboardEntity = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tombstone: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type TimeFrame = {
|
export type TimeFrame = {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
@@ -19,6 +25,7 @@ type AbstractWidget<
|
|||||||
Meta extends Record<string, unknown> | null = null,
|
Meta extends Record<string, unknown> | null = null,
|
||||||
> = {
|
> = {
|
||||||
id: string;
|
id: string;
|
||||||
|
dashboard_page_id: string;
|
||||||
type: T;
|
type: T;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -93,7 +100,7 @@ type SpecializedWidget =
|
|||||||
| CalendarWidget
|
| CalendarWidget
|
||||||
| FormulaWidget;
|
| FormulaWidget;
|
||||||
export type Widget = SpecializedWidget | CustomReportWidget;
|
export type Widget = SpecializedWidget | CustomReportWidget;
|
||||||
export type NewWidget = Omit<Widget, 'id' | 'tombstone'>;
|
export type NewWidget = Omit<Widget, 'id' | 'tombstone' | 'dashboard_page_id'>;
|
||||||
|
|
||||||
// Exported/imported (json) widget definition
|
// Exported/imported (json) widget definition
|
||||||
export type ExportImportCustomReportWidget = Omit<
|
export type ExportImportCustomReportWidget = Omit<
|
||||||
|
|||||||
6
upcoming-release-notes/6411.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Features
|
||||||
|
authors: [Durbatuluk1701]
|
||||||
|
---
|
||||||
|
|
||||||
|
Add multiple tabs/pages to "Reports" allowing for different widget layouts per page
|
||||||