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 { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
|
||||
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
|
||||
import { CopyWidgetToDashboardModal } from './modals/CopyWidgetToDashboardModal';
|
||||
import { CoverModal } from './modals/CoverModal';
|
||||
import { CreateAccountModal } from './modals/CreateAccountModal';
|
||||
import { CreateEncryptionKeyModal } from './modals/CreateEncryptionKeyModal';
|
||||
@@ -148,6 +149,9 @@ export function Modals() {
|
||||
case 'confirm-delete':
|
||||
return <ConfirmDeleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'copy-widget-to-dashboard':
|
||||
return <CopyWidgetToDashboardModal key={key} {...modal.options} />;
|
||||
|
||||
case 'load-backup':
|
||||
return (
|
||||
<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 { Responsive, WidthProvider, type Layout } from 'react-grid-layout';
|
||||
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 { Menu } from '@actual-app/components/menu';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { breakpoints } from '@actual-app/components/tokens';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import {
|
||||
type CustomReportWidget,
|
||||
type ExportImportDashboard,
|
||||
type MarkdownWidget,
|
||||
type Widget,
|
||||
import type {
|
||||
CustomReportWidget,
|
||||
DashboardEntity,
|
||||
ExportImportDashboard,
|
||||
MarkdownWidget,
|
||||
Widget,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
|
||||
import { DashboardHeader } from './DashboardHeader';
|
||||
import { DashboardSelector } from './DashboardSelector';
|
||||
import { LoadingIndicator } from './LoadingIndicator';
|
||||
import { CalendarCard } from './reports/CalendarCard';
|
||||
import { CashFlowCard } from './reports/CashFlowCard';
|
||||
@@ -35,13 +39,12 @@ import './overview.scss';
|
||||
import { SummaryCard } from './reports/SummaryCard';
|
||||
|
||||
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
|
||||
import {
|
||||
MobilePageHeader,
|
||||
Page,
|
||||
PageHeader,
|
||||
} from '@desktop-client/components/Page';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
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 { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useReports } from '@desktop-client/hooks/useReports';
|
||||
@@ -59,7 +62,11 @@ function isCustomReportWidget(widget: Widget): widget is CustomReportWidget {
|
||||
return widget.type === 'custom-report';
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
type OverviewProps = {
|
||||
dashboard: DashboardEntity;
|
||||
};
|
||||
|
||||
export function Overview({ dashboard }: OverviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||
@@ -76,12 +83,16 @@ export function Overview() {
|
||||
|
||||
const { data: customReports, isLoading: isCustomReportsLoading } =
|
||||
useReports();
|
||||
const { data: widgets, isLoading: isWidgetsLoading } = useDashboard();
|
||||
|
||||
const customReportMap = useMemo(
|
||||
() => new Map(customReports.map(report => [report.id, report])),
|
||||
[customReports],
|
||||
);
|
||||
const { data: dashboard_pages = [] } = useDashboardPages();
|
||||
|
||||
const { data: widgets, isLoading: isWidgetsLoading } = useDashboard(
|
||||
dashboard.id,
|
||||
);
|
||||
|
||||
const isLoading = isCustomReportsLoading || isWidgetsLoading;
|
||||
|
||||
@@ -179,7 +190,7 @@ export function Overview() {
|
||||
|
||||
const onResetDashboard = async () => {
|
||||
setIsImporting(true);
|
||||
await send('dashboard-reset');
|
||||
await send('dashboard-reset', dashboard.id);
|
||||
setIsImporting(false);
|
||||
|
||||
onDispatchSucessNotification(
|
||||
@@ -215,6 +226,7 @@ export function Overview() {
|
||||
width: 4,
|
||||
height: 2,
|
||||
meta,
|
||||
dashboard_page_id: dashboard.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -288,7 +300,10 @@ export function Overview() {
|
||||
|
||||
closeNotifications();
|
||||
setIsImporting(true);
|
||||
const res = await send('dashboard-import', { filepath });
|
||||
const res = await send('dashboard-import', {
|
||||
filepath,
|
||||
dashboardPageId: dashboard.id,
|
||||
});
|
||||
setIsImporting(false);
|
||||
|
||||
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();
|
||||
|
||||
if (isLoading) {
|
||||
@@ -356,26 +388,69 @@ export function Overview() {
|
||||
<Page
|
||||
header={
|
||||
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
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginRight: 15,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<PageHeader title={t('Reports')} />
|
||||
<DashboardHeader dashboard={dashboard} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 5,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<Button variant="primary" isDisabled={isImporting}>
|
||||
<Trans>Add new widget</Trans>
|
||||
@@ -471,6 +546,7 @@ export function Overview() {
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
|
||||
{/* The Editing Button */}
|
||||
{isEditing ? (
|
||||
<Button
|
||||
isDisabled={isImporting}
|
||||
@@ -487,6 +563,7 @@ export function Overview() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* The Menu */}
|
||||
<DialogTrigger>
|
||||
<Button variant="bare" aria-label={t('Menu')}>
|
||||
<SvgDotsHorizontalTriple
|
||||
@@ -510,6 +587,9 @@ export function Overview() {
|
||||
case 'import':
|
||||
onImport();
|
||||
break;
|
||||
case 'delete':
|
||||
onDeleteDashboard(dashboard.id);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unrecognized menu option: ${item}`,
|
||||
@@ -533,6 +613,13 @@ export function Overview() {
|
||||
text: t('Export'),
|
||||
disabled: isImporting,
|
||||
},
|
||||
Menu.line,
|
||||
{
|
||||
name: 'delete',
|
||||
text: t('Delete dashboard'),
|
||||
disabled:
|
||||
isImporting || dashboard_pages.length <= 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
@@ -577,6 +664,9 @@ export function Overview() {
|
||||
meta={item.meta}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : item.type === 'crossover-card' &&
|
||||
crossoverReportEnabled ? (
|
||||
@@ -587,6 +677,9 @@ export function Overview() {
|
||||
meta={item.meta}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : item.type === 'cash-flow-card' ? (
|
||||
<CashFlowCard
|
||||
@@ -595,6 +688,9 @@ export function Overview() {
|
||||
meta={item.meta}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : item.type === 'spending-card' ? (
|
||||
<SpendingCard
|
||||
@@ -603,6 +699,9 @@ export function Overview() {
|
||||
meta={item.meta}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : item.type === 'markdown-card' ? (
|
||||
<MarkdownCard
|
||||
@@ -610,12 +709,18 @@ export function Overview() {
|
||||
meta={item.meta}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : item.type === 'custom-report' ? (
|
||||
<CustomReportListCards
|
||||
isEditing={isEditing}
|
||||
report={customReportMap.get(item.meta.id)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : item.type === 'summary-card' ? (
|
||||
<SummaryCard
|
||||
@@ -624,6 +729,9 @@ export function Overview() {
|
||||
meta={item.meta}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : item.type === 'calendar-card' ? (
|
||||
<CalendarCard
|
||||
@@ -633,6 +741,9 @@ export function Overview() {
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : item.type === 'formula-card' && formulaMode ? (
|
||||
<FormulaCard
|
||||
@@ -641,6 +752,9 @@ export function Overview() {
|
||||
meta={item.meta}
|
||||
onMetaChange={newMeta => onMetaChange(item, newMeta)}
|
||||
onRemove={() => onRemoveWidget(item.i)}
|
||||
onCopy={targetDashboardId =>
|
||||
onCopyWidget(item.i, targetDashboardId)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,8 @@ type ReportCardProps = {
|
||||
disableClick?: boolean;
|
||||
to?: string;
|
||||
children: ReactNode;
|
||||
menuItems?: ComponentProps<typeof Menu>['items'];
|
||||
onMenuSelect?: ComponentProps<typeof Menu>['onMenuSelect'];
|
||||
menuItems?: ComponentProps<typeof Menu<string>>['items'];
|
||||
onMenuSelect?: ComponentProps<typeof Menu<string>>['onMenuSelect'];
|
||||
size?: number;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
|
||||
import { Overview } from './Overview';
|
||||
import { Calendar } from './reports/Calendar';
|
||||
import { CashFlow } from './reports/CashFlow';
|
||||
import { Crossover } from './reports/Crossover';
|
||||
@@ -10,6 +9,7 @@ import { Formula } from './reports/Formula';
|
||||
import { NetWorth } from './reports/NetWorth';
|
||||
import { Spending } from './reports/Spending';
|
||||
import { Summary } from './reports/Summary';
|
||||
import { ReportsDashboardRouter } from './ReportsDashboardRouter';
|
||||
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
|
||||
@@ -18,7 +18,8 @@ export function ReportRouter() {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/" element={<ReportsDashboardRouter />} />
|
||||
<Route path="/:dashboardId" element={<ReportsDashboardRouter />} />
|
||||
<Route path="/net-worth" element={<NetWorth />} />
|
||||
<Route path="/net-worth/:id" element={<NetWorth />} />
|
||||
{crossoverReportEnabled && (
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { GraphButton } from './GraphButton';
|
||||
import { SaveReport } from './SaveReport';
|
||||
import { SaveReportWrapper } from './SaveReport';
|
||||
import { setSessionReport } from './setSessionReport';
|
||||
import { SnapshotButton } from './SnapshotButton';
|
||||
|
||||
@@ -40,7 +40,7 @@ type ReportTopbarProps = {
|
||||
viewLabels: boolean;
|
||||
onApplyFilter: (newFilter: RuleConditionEntity) => void;
|
||||
onChangeViews: (viewType: string) => void;
|
||||
onReportChange: ComponentProps<typeof SaveReport>['onReportChange'];
|
||||
onReportChange: ComponentProps<typeof SaveReportWrapper>['onReportChange'];
|
||||
isItemDisabled: (type: string) => boolean;
|
||||
defaultItems: (item: string) => void;
|
||||
};
|
||||
@@ -243,7 +243,7 @@ export function ReportTopbar({
|
||||
}}
|
||||
exclude={[]}
|
||||
/>
|
||||
<SaveReport
|
||||
<SaveReportWrapper
|
||||
customReportItems={customReportItems}
|
||||
report={report}
|
||||
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 { Trans } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgExpandArrow } from '@actual-app/components/icons/v0';
|
||||
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 { View } from '@actual-app/components/view';
|
||||
|
||||
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 { SaveReportDelete } from './SaveReportDelete';
|
||||
import { SaveReportMenu } from './SaveReportMenu';
|
||||
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';
|
||||
|
||||
type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
|
||||
@@ -45,13 +53,28 @@ type SaveReportProps<T extends CustomReportEntity = CustomReportEntity> = {
|
||||
savedReport?: CustomReportEntity;
|
||||
},
|
||||
) => 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({
|
||||
customReportItems,
|
||||
report,
|
||||
savedStatus,
|
||||
onReportChange,
|
||||
dashboardPages,
|
||||
}: SaveReportProps) {
|
||||
const { data: listReports } = useReports();
|
||||
const triggerRef = useRef(null);
|
||||
@@ -63,6 +86,10 @@ export function SaveReport({
|
||||
const [err, setErr] = useState('');
|
||||
const [newName, setNewName] = useState(report.name ?? '');
|
||||
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) {
|
||||
const chooseSavedReport = listReports.find(r => cond === r.id);
|
||||
@@ -82,6 +109,11 @@ export function SaveReport({
|
||||
name: newName,
|
||||
};
|
||||
|
||||
if (!saveDashboardId) {
|
||||
setErr(t('Please select a dashboard to save the report'));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await sendCatch('report/create', newSavedReport);
|
||||
|
||||
if (response.error) {
|
||||
@@ -89,13 +121,12 @@ export function SaveReport({
|
||||
setNameMenuOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to dashboard
|
||||
await send('dashboard-add-widget', {
|
||||
type: 'custom-report',
|
||||
width: 4,
|
||||
height: 2,
|
||||
meta: { id: response.data },
|
||||
dashboard_page_id: saveDashboardId,
|
||||
});
|
||||
|
||||
setNameMenuOpen(false);
|
||||
@@ -202,7 +233,11 @@ export function SaveReport({
|
||||
>
|
||||
{!report.id ? <Trans>Unsaved report</Trans> : report.name}
|
||||
</Text>
|
||||
{savedStatus === 'modified' && <Text>(modified) </Text>}
|
||||
{savedStatus === 'modified' && (
|
||||
<Text>
|
||||
<Trans>(modified)</Trans>
|
||||
</Text>
|
||||
)}
|
||||
<SvgExpandArrow width={8} height={8} style={{ marginRight: 5 }} />
|
||||
</Button>
|
||||
|
||||
@@ -225,14 +260,44 @@ export function SaveReport({
|
||||
onOpenChange={() => setNameMenuOpen(false)}
|
||||
style={{ width: 325 }}
|
||||
>
|
||||
<SaveReportName
|
||||
menuItem={menuItem}
|
||||
name={newName}
|
||||
setName={setNewName}
|
||||
inputRef={inputRef}
|
||||
onAddUpdate={onAddUpdate}
|
||||
err={err}
|
||||
/>
|
||||
<View>
|
||||
<SaveReportName
|
||||
menuItem={menuItem}
|
||||
name={newName}
|
||||
setName={setNewName}
|
||||
inputRef={inputRef}
|
||||
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
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
calendarSpreadsheet,
|
||||
} from '@desktop-client/components/reports/spreadsheets/calendar-spreadsheet';
|
||||
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 { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
@@ -53,6 +54,7 @@ type CalendarCardProps = {
|
||||
meta?: CalendarWidget['meta'];
|
||||
onMetaChange: (newMeta: CalendarWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
firstDayOfWeekIdx?: SyncedPrefs['firstDayOfWeekIdx'];
|
||||
};
|
||||
|
||||
@@ -62,6 +64,7 @@ export function CalendarCard({
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
firstDayOfWeekIdx,
|
||||
}: CalendarCardProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -168,9 +171,13 @@ export function CalendarCard({
|
||||
return data?.calendarData.length;
|
||||
}, [data]);
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
disableClick={nameMenuOpen}
|
||||
to={`/reports/calendar/${widgetId}`}
|
||||
menuItems={[
|
||||
{
|
||||
@@ -181,8 +188,10 @@ export function CalendarCard({
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
setNameMenuOpen(true);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
|
||||
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||
import { simpleCashFlow } from '@desktop-client/components/reports/spreadsheets/cash-flow-spreadsheet';
|
||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
|
||||
type CustomLabelProps = {
|
||||
@@ -104,6 +105,7 @@ type CashFlowCardProps = {
|
||||
meta?: CashFlowWidget['meta'];
|
||||
onMetaChange: (newMeta: CashFlowWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function CashFlowCard({
|
||||
@@ -112,12 +114,16 @@ export function CashFlowCard({
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: CashFlowCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const animationProps = useRechartsAnimation();
|
||||
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestTransaction() {
|
||||
const latestTrans = await send('get-latest-transaction');
|
||||
@@ -162,8 +168,10 @@ export function CashFlowCard({
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
setNameMenuOpen(true);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
|
||||
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||
import { createCrossoverSpreadsheet } from '@desktop-client/components/reports/spreadsheets/crossover-spreadsheet';
|
||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
|
||||
// Type for the return value of the recalculate function
|
||||
@@ -55,6 +56,7 @@ type CrossoverCardProps = {
|
||||
meta?: CrossoverWidget['meta'];
|
||||
onMetaChange: (newMeta: CrossoverWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function CrossoverCard({
|
||||
@@ -64,12 +66,16 @@ export function CrossoverCard({
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: CrossoverCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
|
||||
// Calculate date range from meta or use default range
|
||||
const [start, setStart] = useState<string>('');
|
||||
const [end, setEnd] = useState<string>('');
|
||||
@@ -206,8 +212,10 @@ export function CrossoverCard({
|
||||
menuItems={[
|
||||
{ name: 'rename', text: t('Rename') },
|
||||
{ name: 'remove', text: t('Remove') },
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
setNameMenuOpen(true);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MissingReportCard } from './MissingReportCard';
|
||||
import { DateRange } from '@desktop-client/components/reports/DateRange';
|
||||
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
||||
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 { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
@@ -30,12 +31,14 @@ type CustomReportListCardsProps = {
|
||||
isEditing?: boolean;
|
||||
report?: CustomReportEntity;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function CustomReportListCards({
|
||||
isEditing,
|
||||
report,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: CustomReportListCardsProps) {
|
||||
// It's possible for a dashboard to reference a non-existing
|
||||
// custom report
|
||||
@@ -52,6 +55,7 @@ export function CustomReportListCards({
|
||||
isEditing={isEditing}
|
||||
report={report}
|
||||
onRemove={onRemove}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +64,7 @@ function CustomReportListCardsInner({
|
||||
isEditing,
|
||||
report,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: Omit<CustomReportListCardsProps, 'report'> & {
|
||||
report: CustomReportEntity;
|
||||
}) {
|
||||
@@ -71,6 +76,9 @@ function CustomReportListCardsInner({
|
||||
const [earliestTransaction, setEarliestTransaction] = useState('');
|
||||
const [latestTransaction, setLatestTransaction] = useState('');
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
|
||||
const payees = usePayees();
|
||||
const accounts = useAccounts();
|
||||
const categories = useCategories();
|
||||
@@ -138,8 +146,10 @@ function CustomReportListCardsInner({
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'remove':
|
||||
onRemove();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { type FormulaWidget } from 'loot-core/types/models';
|
||||
import { FormulaResult } from '@desktop-client/components/reports/FormulaResult';
|
||||
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
||||
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
|
||||
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||
import { useFormulaExecution } from '@desktop-client/hooks/useFormulaExecution';
|
||||
import { useThemeColors } from '@desktop-client/hooks/useThemeColors';
|
||||
|
||||
@@ -17,6 +18,7 @@ type FormulaCardProps = {
|
||||
meta?: FormulaWidget['meta'];
|
||||
onMetaChange: (newMeta: FormulaWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function FormulaCard({
|
||||
@@ -25,9 +27,12 @@ export function FormulaCard({
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: FormulaCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
const themeColors = useThemeColors();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -81,8 +86,10 @@ export function FormulaCard({
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
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 { ReportCard } from '@desktop-client/components/reports/ReportCard';
|
||||
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||
import {
|
||||
remarkBreaks,
|
||||
sequentialNewlinesPlugin,
|
||||
@@ -38,6 +39,7 @@ type MarkdownCardProps = {
|
||||
meta: MarkdownWidget['meta'];
|
||||
onMetaChange: (newMeta: MarkdownWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function MarkdownCard({
|
||||
@@ -45,11 +47,15 @@ export function MarkdownCard({
|
||||
meta,
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: MarkdownCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isVisibleTextArea, setIsVisibleTextArea] = useState(false);
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
@@ -81,8 +87,10 @@ export function MarkdownCard({
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'text-left':
|
||||
onMetaChange({
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
|
||||
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||
import { createSpreadsheet as netWorthSpreadsheet } from '@desktop-client/components/reports/spreadsheets/net-worth-spreadsheet';
|
||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
@@ -34,6 +35,7 @@ type NetWorthCardProps = {
|
||||
meta?: NetWorthWidget['meta'];
|
||||
onMetaChange: (newMeta: NetWorthWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function NetWorthCard({
|
||||
@@ -43,6 +45,7 @@ export function NetWorthCard({
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: NetWorthCardProps) {
|
||||
const locale = useLocale();
|
||||
const { t } = useTranslation();
|
||||
@@ -55,6 +58,9 @@ export function NetWorthCard({
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestTransaction() {
|
||||
const latestTrans = await send('get-latest-transaction');
|
||||
@@ -114,8 +120,10 @@ export function NetWorthCard({
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
setNameMenuOpen(true);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ReportCardName } from '@desktop-client/components/reports/ReportCardNam
|
||||
import { calculateSpendingReportTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||
import { createSpendingSpreadsheet } from '@desktop-client/components/reports/spreadsheets/spending-spreadsheet';
|
||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
|
||||
type SpendingCardProps = {
|
||||
@@ -26,6 +27,7 @@ type SpendingCardProps = {
|
||||
meta?: SpendingWidget['meta'];
|
||||
onMetaChange: (newMeta: SpendingWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function SpendingCard({
|
||||
@@ -34,12 +36,17 @@ export function SpendingCard({
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: SpendingCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
|
||||
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
|
||||
const spendingReportMode = meta?.mode ?? 'single-month';
|
||||
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
|
||||
@@ -83,8 +90,10 @@ export function SpendingCard({
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
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 { SummaryNumber } from '@desktop-client/components/reports/SummaryNumber';
|
||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
|
||||
type SummaryCardProps = {
|
||||
@@ -26,6 +27,7 @@ type SummaryCardProps = {
|
||||
meta?: SummaryWidget['meta'];
|
||||
onMetaChange: (newMeta: SummaryWidget['meta']) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: (targetDashboardId: string) => void;
|
||||
};
|
||||
|
||||
export function SummaryCard({
|
||||
@@ -34,12 +36,16 @@ export function SummaryCard({
|
||||
meta = {},
|
||||
onMetaChange,
|
||||
onRemove,
|
||||
onCopy,
|
||||
}: SummaryCardProps) {
|
||||
const locale = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
const { menuItems: copyMenuItems, handleMenuSelect: handleCopyMenuSelect } =
|
||||
useWidgetCopyMenu(onCopy);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestTransaction() {
|
||||
const latestTrans = await send('get-latest-transaction');
|
||||
@@ -104,8 +110,10 @@ export function SummaryCard({
|
||||
name: 'remove',
|
||||
text: t('Remove'),
|
||||
},
|
||||
...copyMenuItems,
|
||||
]}
|
||||
onMenuSelect={item => {
|
||||
if (handleCopyMenuSelect(item)) return;
|
||||
switch (item) {
|
||||
case 'rename':
|
||||
setNameMenuOpen(true);
|
||||
@@ -114,8 +122,7 @@ export function SummaryCard({
|
||||
onRemove();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unrecognized menu selection: ${item}`);
|
||||
break;
|
||||
throw new Error(`Unrecognized menu selection: ${item}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 { 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';
|
||||
|
||||
export function useDashboard() {
|
||||
const { data: queryData, isLoading } = useQuery<Widget>(
|
||||
() => q('dashboard').select('*'),
|
||||
export function useDashboard(dashboardPageId: string) {
|
||||
const { data: queryData, isLoading } = useQuery<Widget>(() => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'copy-widget-to-dashboard';
|
||||
options: {
|
||||
onSelect: (dashboardId: string) => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'edit-user';
|
||||
options: {
|
||||
|
||||