mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Compare commits
25 Commits
ts-db-sele
...
accounts-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c285392c3c | ||
|
|
3fe5b5aaf0 | ||
|
|
4fc91c6b4c | ||
|
|
45777cad8d | ||
|
|
72644d3d51 | ||
|
|
5e805085b0 | ||
|
|
4e6331c7f0 | ||
|
|
2ec46d8dec | ||
|
|
bb17f6a6f1 | ||
|
|
ba70fca304 | ||
|
|
3ace7d199d | ||
|
|
2a93b173e0 | ||
|
|
e9175951dd | ||
|
|
3ba94642d9 | ||
|
|
bc3b96c9b2 | ||
|
|
ccff8412d3 | ||
|
|
df61e42fda | ||
|
|
0726760084 | ||
|
|
a7f65532fb | ||
|
|
89059bf5da | ||
|
|
b9c167d5d6 | ||
|
|
60dac66898 | ||
|
|
b9a43b992a | ||
|
|
a7b90a0945 | ||
|
|
fbc2ccd2e7 |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 105 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useHover } from 'usehooks-ts';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { isPreviewId } from 'loot-core/shared/transactions';
|
||||
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
|
||||
@@ -12,7 +12,7 @@ import { type AccountEntity } from 'loot-core/types/models';
|
||||
import { useSelectedItems } from '../../hooks/useSelected';
|
||||
import { SvgArrowButtonRight1 } from '../../icons/v2';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { ButtonWithLoading } from '../common/Button2';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { PrivacyFilter } from '../PrivacyFilter';
|
||||
@@ -56,10 +56,10 @@ function DetailedBalance({
|
||||
|
||||
type SelectedBalanceProps = {
|
||||
selectedItems: Set<string>;
|
||||
account?: AccountEntity;
|
||||
accountId?: AccountEntity['id'];
|
||||
};
|
||||
|
||||
function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) {
|
||||
function SelectedBalance({ selectedItems, accountId }: SelectedBalanceProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const name = `selected-balance-${[...selectedItems].join('-')}`;
|
||||
@@ -104,7 +104,7 @@ function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) {
|
||||
isExactBalance = false;
|
||||
}
|
||||
|
||||
if (!account || account.id === s._account) {
|
||||
if (accountId !== s._account) {
|
||||
scheduleBalance += getScheduledAmount(s._amount);
|
||||
} else {
|
||||
scheduleBalance -= getScheduledAmount(s._amount);
|
||||
@@ -128,39 +128,53 @@ function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) {
|
||||
}
|
||||
|
||||
type FilteredBalanceProps = {
|
||||
filteredAmount?: number | null;
|
||||
transactionsQuery: Query;
|
||||
};
|
||||
|
||||
function FilteredBalance({ filteredAmount }: FilteredBalanceProps) {
|
||||
function FilteredBalance({ transactionsQuery }: FilteredBalanceProps) {
|
||||
const { t } = useTranslation();
|
||||
const filteredBalance = useSheetValue<'balance', 'filtered-balance'>({
|
||||
name: 'filtered-balance',
|
||||
query: transactionsQuery.calculate({ $sum: '$amount' }),
|
||||
value: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<DetailedBalance
|
||||
name={t('Filtered balance:')}
|
||||
balance={filteredAmount ?? 0}
|
||||
balance={filteredBalance || 0}
|
||||
isExactBalance={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type MoreBalancesProps = {
|
||||
balanceQuery: { name: `balance-query-${string}`; query: Query };
|
||||
accountId: AccountEntity['id'] | string;
|
||||
balanceQuery: Query;
|
||||
};
|
||||
|
||||
function MoreBalances({ balanceQuery }: MoreBalancesProps) {
|
||||
function MoreBalances({ accountId, balanceQuery }: MoreBalancesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const clearedQuery = useMemo(
|
||||
() => balanceQuery.filter({ cleared: true }),
|
||||
[balanceQuery],
|
||||
);
|
||||
const cleared = useSheetValue<'balance', `balance-query-${string}-cleared`>({
|
||||
name: (balanceQuery.name + '-cleared') as `balance-query-${string}-cleared`,
|
||||
query: balanceQuery.query.filter({ cleared: true }),
|
||||
name: `balance-query-${accountId}-cleared`,
|
||||
query: clearedQuery,
|
||||
});
|
||||
|
||||
const unclearedQuery = useMemo(
|
||||
() => balanceQuery.filter({ cleared: false }),
|
||||
[balanceQuery],
|
||||
);
|
||||
const uncleared = useSheetValue<
|
||||
'balance',
|
||||
`balance-query-${string}-uncleared`
|
||||
>({
|
||||
name: (balanceQuery.name +
|
||||
'-uncleared') as `balance-query-${string}-uncleared`,
|
||||
query: balanceQuery.query.filter({ cleared: false }),
|
||||
name: `balance-query-${accountId}-uncleared`,
|
||||
query: unclearedQuery,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -172,25 +186,31 @@ function MoreBalances({ balanceQuery }: MoreBalancesProps) {
|
||||
}
|
||||
|
||||
type BalancesProps = {
|
||||
balanceQuery: { name: `balance-query-${string}`; query: Query };
|
||||
accountId?: AccountEntity['id'] | string;
|
||||
showFilteredBalance: boolean;
|
||||
transactionsQuery?: Query;
|
||||
balanceQuery: Query;
|
||||
showExtraBalances: boolean;
|
||||
onToggleExtraBalances: () => void;
|
||||
account?: AccountEntity;
|
||||
isFiltered: boolean;
|
||||
filteredAmount?: number | null;
|
||||
};
|
||||
|
||||
export function Balances({
|
||||
accountId,
|
||||
balanceQuery,
|
||||
transactionsQuery,
|
||||
showFilteredBalance,
|
||||
showExtraBalances,
|
||||
onToggleExtraBalances,
|
||||
account,
|
||||
isFiltered,
|
||||
filteredAmount,
|
||||
}: BalancesProps) {
|
||||
const selectedItems = useSelectedItems();
|
||||
const buttonRef = useRef(null);
|
||||
const isButtonHovered = useHover(buttonRef);
|
||||
const balanceBinding = useMemo<Binding<'balance', `balance-query-${string}`>>(
|
||||
() => ({
|
||||
name: `balance-query-${accountId}`,
|
||||
query: balanceQuery,
|
||||
value: 0,
|
||||
}),
|
||||
[accountId, balanceQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -201,25 +221,28 @@ export function Balances({
|
||||
marginLeft: -5,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
<ButtonWithLoading
|
||||
isLoading={!balanceQuery}
|
||||
data-testid="account-balance"
|
||||
variant="bare"
|
||||
onPress={onToggleExtraBalances}
|
||||
style={{
|
||||
className={css({
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
}}
|
||||
[`& svg`]: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
marginLeft: 10,
|
||||
color: theme.pillText,
|
||||
transform: showExtraBalances ? 'rotateZ(180deg)' : 'rotateZ(0)',
|
||||
opacity: selectedItems.size > 0 || showExtraBalances ? 1 : 0,
|
||||
},
|
||||
[`&[data-hovered] svg`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<CellValue
|
||||
binding={
|
||||
{ ...balanceQuery, value: 0 } as Binding<
|
||||
'balance',
|
||||
`balance-query-${string}`
|
||||
>
|
||||
}
|
||||
type="financial"
|
||||
>
|
||||
<CellValue binding={balanceBinding} type="financial">
|
||||
{props => (
|
||||
<CellValueText
|
||||
{...props}
|
||||
@@ -237,26 +260,17 @@ export function Balances({
|
||||
)}
|
||||
</CellValue>
|
||||
|
||||
<SvgArrowButtonRight1
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
marginLeft: 10,
|
||||
color: theme.pillText,
|
||||
transform: showExtraBalances ? 'rotateZ(180deg)' : 'rotateZ(0)',
|
||||
opacity:
|
||||
isButtonHovered || selectedItems.size > 0 || showExtraBalances
|
||||
? 1
|
||||
: 0,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{showExtraBalances && <MoreBalances balanceQuery={balanceQuery} />}
|
||||
|
||||
{selectedItems.size > 0 && (
|
||||
<SelectedBalance selectedItems={selectedItems} account={account} />
|
||||
<SvgArrowButtonRight1 />
|
||||
</ButtonWithLoading>
|
||||
{showExtraBalances && accountId && balanceQuery && (
|
||||
<MoreBalances accountId={accountId} balanceQuery={balanceQuery} />
|
||||
)}
|
||||
{selectedItems.size > 0 && (
|
||||
<SelectedBalance selectedItems={selectedItems} accountId={accountId} />
|
||||
)}
|
||||
{showFilteredBalance && transactionsQuery && (
|
||||
<FilteredBalance transactionsQuery={transactionsQuery} />
|
||||
)}
|
||||
{isFiltered && <FilteredBalance filteredAmount={filteredAmount} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import React, {
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
type ComponentProps,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { type Query } from 'loot-core/shared/query';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type RuleConditionEntity,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { useSplitsExpanded } from '../../hooks/useSplitsExpanded';
|
||||
import { useSyncServerStatus } from '../../hooks/useSyncServerStatus';
|
||||
import { AnimatedLoading } from '../../icons/AnimatedLoading';
|
||||
// import { AnimatedLoading } from '../../icons/AnimatedLoading';
|
||||
import { SvgAdd } from '../../icons/v1';
|
||||
import {
|
||||
SvgArrowsExpand3,
|
||||
@@ -40,7 +43,6 @@ import { Stack } from '../common/Stack';
|
||||
import { View } from '../common/View';
|
||||
import { FilterButton } from '../filters/FiltersMenu';
|
||||
import { FiltersStack } from '../filters/FiltersStack';
|
||||
import { type SavedFilter } from '../filters/SavedFilterMenuButton';
|
||||
import { NotesButton } from '../NotesButton';
|
||||
import { SelectedTransactionsButton } from '../transactions/SelectedTransactionsButton';
|
||||
|
||||
@@ -52,29 +54,28 @@ type AccountHeaderProps = {
|
||||
tableRef: TableRef;
|
||||
editingName: boolean;
|
||||
isNameEditable: boolean;
|
||||
workingHard: boolean;
|
||||
accountName: string;
|
||||
isLoading: boolean;
|
||||
accountId?: AccountEntity['id'] | string;
|
||||
accountName: string | null;
|
||||
account?: AccountEntity;
|
||||
filterId?: SavedFilter;
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
activeFilter?: TransactionFilterEntity;
|
||||
dirtyFilter?: TransactionFilterEntity;
|
||||
accountsSyncing: string[];
|
||||
failedAccounts: AccountSyncSidebarProps['failedAccounts'];
|
||||
accounts: AccountEntity[];
|
||||
transactions: TransactionEntity[];
|
||||
transactions: readonly TransactionEntity[];
|
||||
showBalances: boolean;
|
||||
showExtraBalances: boolean;
|
||||
showCleared: boolean;
|
||||
showReconciled: boolean;
|
||||
showEmptyMessage: boolean;
|
||||
balanceQuery: ComponentProps<typeof ReconcilingMessage>['balanceQuery'];
|
||||
reconcileAmount?: number | null;
|
||||
canCalculateBalance?: () => boolean;
|
||||
isFiltered: boolean;
|
||||
filteredAmount?: number | null;
|
||||
balanceQuery: Query;
|
||||
transactionsQuery?: Query;
|
||||
reconcileAmount: number | null;
|
||||
showFilteredBalance: boolean;
|
||||
isSorted: boolean;
|
||||
search: string;
|
||||
filterConditions: RuleConditionEntity[];
|
||||
filterConditionsOp: 'and' | 'or';
|
||||
filterConditions: readonly RuleConditionEntity[];
|
||||
filterConditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
onSearch: (newSearch: string) => void;
|
||||
onAddTransaction: () => void;
|
||||
onShowTransactions: ComponentProps<
|
||||
@@ -127,11 +128,12 @@ export function AccountHeader({
|
||||
tableRef,
|
||||
editingName,
|
||||
isNameEditable,
|
||||
workingHard,
|
||||
isLoading,
|
||||
accountId,
|
||||
accountName,
|
||||
account,
|
||||
filterId,
|
||||
savedFilters,
|
||||
activeFilter,
|
||||
dirtyFilter,
|
||||
accountsSyncing,
|
||||
failedAccounts,
|
||||
accounts,
|
||||
@@ -143,11 +145,9 @@ export function AccountHeader({
|
||||
showEmptyMessage,
|
||||
balanceQuery,
|
||||
reconcileAmount,
|
||||
canCalculateBalance,
|
||||
isFiltered,
|
||||
filteredAmount,
|
||||
showFilteredBalance,
|
||||
transactionsQuery,
|
||||
isSorted,
|
||||
search,
|
||||
filterConditions,
|
||||
filterConditionsOp,
|
||||
onSearch,
|
||||
@@ -191,6 +191,7 @@ export function AccountHeader({
|
||||
const isUsingServer = syncServerStatus !== 'no-server';
|
||||
const isServerOffline = syncServerStatus === 'offline';
|
||||
const [_, setExpandSplitsPref] = useLocalPref('expand-splits');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
let canSync = !!(account?.account_id && isUsingServer);
|
||||
if (!account) {
|
||||
@@ -254,6 +255,19 @@ export function AccountHeader({
|
||||
[onSync],
|
||||
);
|
||||
|
||||
const onSearchChange = useCallback(
|
||||
(search: string) => {
|
||||
setSearch(search);
|
||||
onSearch?.(search);
|
||||
},
|
||||
[onSearch],
|
||||
);
|
||||
|
||||
const transactionsMap = useMemo(
|
||||
() => new Map(transactions.map(t => [t.id, t])),
|
||||
[transactions],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ ...styles.pageContent, paddingBottom: 10, flexShrink: 0 }}>
|
||||
@@ -276,7 +290,7 @@ export function AccountHeader({
|
||||
)}
|
||||
<AccountNameField
|
||||
account={account}
|
||||
accountName={accountName}
|
||||
accountName={accountName || ''}
|
||||
isNameEditable={isNameEditable}
|
||||
editingName={editingName}
|
||||
saveNameError={saveNameError}
|
||||
@@ -287,12 +301,12 @@ export function AccountHeader({
|
||||
</View>
|
||||
|
||||
<Balances
|
||||
accountId={accountId}
|
||||
balanceQuery={balanceQuery}
|
||||
showExtraBalances={showExtraBalances}
|
||||
transactionsQuery={transactionsQuery}
|
||||
showFilteredBalance={showFilteredBalance}
|
||||
showExtraBalances={!showFilteredBalance && showExtraBalances}
|
||||
onToggleExtraBalances={onToggleExtraBalances}
|
||||
account={account}
|
||||
isFiltered={isFiltered}
|
||||
filteredAmount={filteredAmount}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
@@ -345,30 +359,25 @@ export function AccountHeader({
|
||||
<Search
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChange={onSearch}
|
||||
onChange={onSearchChange}
|
||||
inputRef={searchInput}
|
||||
/>
|
||||
{workingHard ? (
|
||||
<View>
|
||||
<AnimatedLoading style={{ width: 16, height: 16 }} />
|
||||
</View>
|
||||
) : (
|
||||
<SelectedTransactionsButton
|
||||
getTransaction={id => transactions.find(t => t.id === id)}
|
||||
onShow={onShowTransactions}
|
||||
onDuplicate={onBatchDuplicate}
|
||||
onDelete={onBatchDelete}
|
||||
onEdit={onBatchEdit}
|
||||
onLinkSchedule={onBatchLinkSchedule}
|
||||
onUnlinkSchedule={onBatchUnlinkSchedule}
|
||||
onCreateRule={onCreateRule}
|
||||
onSetTransfer={onSetTransfer}
|
||||
onScheduleAction={onScheduleAction}
|
||||
showMakeTransfer={showMakeTransfer}
|
||||
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
|
||||
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
|
||||
/>
|
||||
)}
|
||||
<SelectedTransactionsButton
|
||||
isLoading={isLoading}
|
||||
getTransaction={id => transactionsMap.get(id)}
|
||||
onShow={onShowTransactions}
|
||||
onDuplicate={onBatchDuplicate}
|
||||
onDelete={onBatchDelete}
|
||||
onEdit={onBatchEdit}
|
||||
onLinkSchedule={onBatchLinkSchedule}
|
||||
onUnlinkSchedule={onBatchUnlinkSchedule}
|
||||
onCreateRule={onCreateRule}
|
||||
onSetTransfer={onSetTransfer}
|
||||
onScheduleAction={onScheduleAction}
|
||||
showMakeTransfer={showMakeTransfer}
|
||||
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
|
||||
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
|
||||
/>
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
{account && (
|
||||
<>
|
||||
@@ -443,9 +452,7 @@ export function AccountHeader({
|
||||
<AccountMenu
|
||||
account={account}
|
||||
canSync={canSync}
|
||||
canShowBalances={
|
||||
canCalculateBalance ? canCalculateBalance() : false
|
||||
}
|
||||
// canShowBalances={canCalculateBalance()}
|
||||
isSorted={isSorted}
|
||||
showBalances={showBalances}
|
||||
showCleared={showCleared}
|
||||
@@ -500,14 +507,15 @@ export function AccountHeader({
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onClearFilters={onClearFilters}
|
||||
onReloadSavedFilter={onReloadSavedFilter}
|
||||
filterId={filterId}
|
||||
savedFilters={savedFilters}
|
||||
filter={activeFilter}
|
||||
dirtyFilter={dirtyFilter}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{reconcileAmount != null && (
|
||||
<ReconcilingMessage
|
||||
accountId={accountId}
|
||||
targetBalance={reconcileAmount}
|
||||
balanceQuery={balanceQuery}
|
||||
onDone={onDoneReconciling}
|
||||
@@ -590,7 +598,7 @@ function AccountNameField({
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, accountName.length) + 'ch',
|
||||
width: Math.max(20, accountName?.length ?? 0) + 'ch',
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
@@ -671,7 +679,7 @@ type AccountMenuProps = {
|
||||
account: AccountEntity;
|
||||
canSync: boolean;
|
||||
showBalances: boolean;
|
||||
canShowBalances: boolean;
|
||||
// canShowBalances: boolean;
|
||||
showCleared: boolean;
|
||||
showReconciled: boolean;
|
||||
isSorted: boolean;
|
||||
@@ -693,7 +701,7 @@ function AccountMenu({
|
||||
account,
|
||||
canSync,
|
||||
showBalances,
|
||||
canShowBalances,
|
||||
// canShowBalances,
|
||||
showCleared,
|
||||
showReconciled,
|
||||
isSorted,
|
||||
@@ -716,16 +724,22 @@ function AccountMenu({
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
...(canShowBalances
|
||||
? [
|
||||
{
|
||||
name: 'toggle-balance',
|
||||
text: showBalances
|
||||
? t('Hide running balance')
|
||||
: t('Show running balance'),
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
// ...(canShowBalances
|
||||
// ? [
|
||||
// {
|
||||
// name: 'toggle-balance',
|
||||
// text: showBalances
|
||||
// ? t('Hide running balance')
|
||||
// : t('Show running balance'),
|
||||
// } as const,
|
||||
// ]
|
||||
// : []),[
|
||||
{
|
||||
name: 'toggle-balance',
|
||||
text: showBalances
|
||||
? t('Hide running balance')
|
||||
: t('Show running balance'),
|
||||
},
|
||||
{
|
||||
name: 'toggle-cleared',
|
||||
text: showCleared
|
||||
|
||||
@@ -17,13 +17,15 @@ import { useFormat } from '../spreadsheet/useFormat';
|
||||
import { useSheetValue } from '../spreadsheet/useSheetValue';
|
||||
|
||||
type ReconcilingMessageProps = {
|
||||
balanceQuery: { name: `balance-query-${string}`; query: Query };
|
||||
accountId?: AccountEntity['id'] | string;
|
||||
balanceQuery: Query;
|
||||
targetBalance: number;
|
||||
onDone: () => void;
|
||||
onCreateTransaction: (targetDiff: number) => void;
|
||||
};
|
||||
|
||||
export function ReconcilingMessage({
|
||||
accountId,
|
||||
balanceQuery,
|
||||
targetBalance,
|
||||
onDone,
|
||||
@@ -31,11 +33,10 @@ export function ReconcilingMessage({
|
||||
}: ReconcilingMessageProps) {
|
||||
const cleared =
|
||||
useSheetValue<'balance', `balance-query-${string}-cleared`>({
|
||||
name: (balanceQuery.name +
|
||||
'-cleared') as `balance-query-${string}-cleared`,
|
||||
name: `balance-query-${accountId}-cleared`,
|
||||
value: 0,
|
||||
query: balanceQuery.query.filter({ cleared: true }),
|
||||
}) ?? 0;
|
||||
query: balanceQuery.filter({ cleared: true }),
|
||||
} as const) ?? 0;
|
||||
const format = useFormat();
|
||||
const targetDiff = targetBalance - cleared;
|
||||
|
||||
|
||||
@@ -8,14 +8,16 @@ import { ConditionsOpMenu } from './ConditionsOpMenu';
|
||||
import { FilterExpression } from './FilterExpression';
|
||||
|
||||
type AppliedFiltersProps = {
|
||||
conditions: RuleConditionEntity[];
|
||||
conditions: readonly RuleConditionEntity[];
|
||||
onUpdate: (
|
||||
filter: RuleConditionEntity,
|
||||
newFilter: RuleConditionEntity,
|
||||
filterCondition: RuleConditionEntity,
|
||||
newFilterCondition: RuleConditionEntity,
|
||||
) => void;
|
||||
onDelete: (filterCondition: RuleConditionEntity) => void;
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
onConditionsOpChange: (
|
||||
filterConditionsOp: RuleConditionEntity['conditionsOp'],
|
||||
) => void;
|
||||
onDelete: (filter: RuleConditionEntity) => void;
|
||||
conditionsOp: string;
|
||||
onConditionsOpChange: (value: 'and' | 'or') => void;
|
||||
};
|
||||
|
||||
export function AppliedFilters({
|
||||
|
||||
@@ -12,9 +12,9 @@ export function ConditionsOpMenu({
|
||||
onChange,
|
||||
conditions,
|
||||
}: {
|
||||
conditionsOp: string;
|
||||
onChange: (value: 'and' | 'or') => void;
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
onChange: (op: RuleConditionEntity['conditionsOp']) => void;
|
||||
conditions: readonly RuleConditionEntity[];
|
||||
}) {
|
||||
return conditions.length > 1 ? (
|
||||
<Text style={{ color: theme.pageText, marginTop: 11, marginRight: 5 }}>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type TransactionFilterEntity } from 'loot-core/types/models';
|
||||
|
||||
import { Menu } from '../common/Menu';
|
||||
|
||||
import { type SavedFilter } from './SavedFilterMenuButton';
|
||||
|
||||
export function FilterMenu({
|
||||
filterId,
|
||||
filter,
|
||||
dirtyFilter,
|
||||
onFilterMenuSelect,
|
||||
}: {
|
||||
filterId?: SavedFilter;
|
||||
filter?: TransactionFilterEntity;
|
||||
dirtyFilter?: TransactionFilterEntity;
|
||||
onFilterMenuSelect: (item: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,12 +22,12 @@ export function FilterMenu({
|
||||
onFilterMenuSelect(item);
|
||||
}}
|
||||
items={
|
||||
!filterId?.id
|
||||
!filter?.id
|
||||
? [
|
||||
{ name: 'save-filter', text: t('Save new filter') },
|
||||
{ name: 'clear-filter', text: t('Clear all conditions') },
|
||||
{ name: 'clear-filter', text: t('Clear all filter conditions') },
|
||||
]
|
||||
: filterId?.id !== null && filterId?.status === 'saved'
|
||||
: filter?.id !== null && !dirtyFilter
|
||||
? [
|
||||
{ name: 'rename-filter', text: t('Rename') },
|
||||
{ name: 'delete-filter', text: t('Delete') },
|
||||
@@ -35,16 +37,22 @@ export function FilterMenu({
|
||||
text: t('Save new filter'),
|
||||
disabled: true,
|
||||
},
|
||||
{ name: 'clear-filter', text: t('Clear all conditions') },
|
||||
{
|
||||
name: 'clear-filter',
|
||||
text: t('Clear all filter conditions'),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ name: 'rename-filter', text: t('Rename') },
|
||||
{ name: 'update-filter', text: t('Update condtions') },
|
||||
{ name: 'update-filter', text: t('Update filter conditions') },
|
||||
{ name: 'reload-filter', text: t('Revert changes') },
|
||||
{ name: 'delete-filter', text: t('Delete') },
|
||||
Menu.line,
|
||||
{ name: 'save-filter', text: t('Save new filter') },
|
||||
{ name: 'clear-filter', text: t('Clear all conditions') },
|
||||
{
|
||||
name: 'clear-filter',
|
||||
text: t('Clear all filter conditions'),
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,27 @@ import { Stack } from '../common/Stack';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { AppliedFilters } from './AppliedFilters';
|
||||
import {
|
||||
type SavedFilter,
|
||||
SavedFilterMenuButton,
|
||||
} from './SavedFilterMenuButton';
|
||||
import { SavedFilterMenuButton } from './SavedFilterMenuButton';
|
||||
|
||||
type FiltersStackProps = {
|
||||
conditions: readonly RuleConditionEntity[];
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
onUpdateFilter: (
|
||||
filterCondition: RuleConditionEntity,
|
||||
newFilterCondition: RuleConditionEntity,
|
||||
) => void;
|
||||
onDeleteFilter: (filterCondition: RuleConditionEntity) => void;
|
||||
onClearFilters: () => void;
|
||||
onReloadSavedFilter: (
|
||||
savedFilter: TransactionFilterEntity,
|
||||
action?: 'reload' | 'update',
|
||||
) => void;
|
||||
filter?: TransactionFilterEntity;
|
||||
dirtyFilter?: TransactionFilterEntity;
|
||||
onConditionsOpChange: (
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'],
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function FiltersStack({
|
||||
conditions,
|
||||
@@ -19,23 +36,10 @@ export function FiltersStack({
|
||||
onDeleteFilter,
|
||||
onClearFilters,
|
||||
onReloadSavedFilter,
|
||||
filterId,
|
||||
savedFilters,
|
||||
filter,
|
||||
dirtyFilter,
|
||||
onConditionsOpChange,
|
||||
}: {
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: 'and' | 'or';
|
||||
onUpdateFilter: (
|
||||
filter: RuleConditionEntity,
|
||||
newFilter: RuleConditionEntity,
|
||||
) => void;
|
||||
onDeleteFilter: (filter: RuleConditionEntity) => void;
|
||||
onClearFilters: () => void;
|
||||
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
|
||||
filterId?: SavedFilter;
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
onConditionsOpChange: (value: 'and' | 'or') => void;
|
||||
}) {
|
||||
}: FiltersStackProps) {
|
||||
return (
|
||||
<View>
|
||||
<Stack
|
||||
@@ -55,10 +59,10 @@ export function FiltersStack({
|
||||
<SavedFilterMenuButton
|
||||
conditions={conditions}
|
||||
conditionsOp={conditionsOp}
|
||||
filterId={filterId}
|
||||
filter={filter}
|
||||
dirtyFilter={dirtyFilter}
|
||||
onClearFilters={onClearFilters}
|
||||
onReloadSavedFilter={onReloadSavedFilter}
|
||||
savedFilters={savedFilters}
|
||||
/>
|
||||
</Stack>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFilters } from 'loot-core/client/data-hooks/filters';
|
||||
import { send, sendCatch } from 'loot-core/src/platform/client/fetch';
|
||||
import { type TransactionFilterEntity } from 'loot-core/types/models';
|
||||
import { type RuleConditionEntity } from 'loot-core/types/models/rule';
|
||||
@@ -14,29 +15,26 @@ import { View } from '../common/View';
|
||||
import { FilterMenu } from './FilterMenu';
|
||||
import { NameFilter } from './NameFilter';
|
||||
|
||||
export type SavedFilter = {
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
id?: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
type SavedFilterMenuButtonProps = {
|
||||
conditions: readonly RuleConditionEntity[];
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
filter?: TransactionFilterEntity;
|
||||
dirtyFilter?: TransactionFilterEntity;
|
||||
onClearFilters: () => void;
|
||||
onReloadSavedFilter: (
|
||||
savedFilter: TransactionFilterEntity,
|
||||
action?: 'reload' | 'update',
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function SavedFilterMenuButton({
|
||||
conditions,
|
||||
conditionsOp,
|
||||
filterId,
|
||||
filter,
|
||||
dirtyFilter,
|
||||
onClearFilters,
|
||||
onReloadSavedFilter,
|
||||
savedFilters,
|
||||
}: {
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: 'and' | 'or';
|
||||
filterId?: SavedFilter;
|
||||
onClearFilters: () => void;
|
||||
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
}) {
|
||||
}: SavedFilterMenuButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [nameOpen, setNameOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -44,9 +42,8 @@ export function SavedFilterMenuButton({
|
||||
const triggerRef = useRef(null);
|
||||
const [err, setErr] = useState(null);
|
||||
const [menuItem, setMenuItem] = useState('');
|
||||
const [name, setName] = useState(filterId?.name ?? '');
|
||||
const id = filterId?.id;
|
||||
let savedFilter: SavedFilter;
|
||||
const [name, setName] = useState(filter?.name ?? '');
|
||||
const savedFilters = useFilters();
|
||||
|
||||
const onFilterMenuSelect = async (item: string) => {
|
||||
setMenuItem(item);
|
||||
@@ -59,23 +56,22 @@ export function SavedFilterMenuButton({
|
||||
break;
|
||||
case 'delete-filter':
|
||||
setMenuOpen(false);
|
||||
await send('filter-delete', id);
|
||||
if (filter?.id) {
|
||||
await send('filter-delete', filter.id);
|
||||
}
|
||||
onClearFilters();
|
||||
break;
|
||||
case 'update-filter':
|
||||
setErr(null);
|
||||
setAdding(false);
|
||||
setMenuOpen(false);
|
||||
savedFilter = {
|
||||
conditions,
|
||||
conditionsOp,
|
||||
id: filterId?.id,
|
||||
name: filterId?.name ?? '',
|
||||
status: 'saved',
|
||||
};
|
||||
if (!filter || !dirtyFilter) {
|
||||
// No active filter or filter is not dirty, nothing to update.
|
||||
return;
|
||||
}
|
||||
const response = await sendCatch('filter-update', {
|
||||
state: savedFilter,
|
||||
filters: [...savedFilters],
|
||||
state: dirtyFilter,
|
||||
filters: savedFilters,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -84,7 +80,7 @@ export function SavedFilterMenuButton({
|
||||
return;
|
||||
}
|
||||
|
||||
onReloadSavedFilter(savedFilter, 'update');
|
||||
onReloadSavedFilter(dirtyFilter, 'update');
|
||||
break;
|
||||
case 'save-filter':
|
||||
setErr(null);
|
||||
@@ -94,11 +90,9 @@ export function SavedFilterMenuButton({
|
||||
break;
|
||||
case 'reload-filter':
|
||||
setMenuOpen(false);
|
||||
savedFilter = {
|
||||
...savedFilter,
|
||||
status: 'saved',
|
||||
};
|
||||
onReloadSavedFilter(savedFilter, 'reload');
|
||||
if (filter) {
|
||||
onReloadSavedFilter(filter, 'reload');
|
||||
}
|
||||
break;
|
||||
case 'clear-filter':
|
||||
setMenuOpen(false);
|
||||
@@ -111,10 +105,9 @@ export function SavedFilterMenuButton({
|
||||
async function onAddUpdate() {
|
||||
if (adding) {
|
||||
const newSavedFilter = {
|
||||
conditions,
|
||||
conditionsOp,
|
||||
conditions: [...conditions],
|
||||
conditionsOp: conditionsOp || 'and',
|
||||
name,
|
||||
status: 'saved',
|
||||
};
|
||||
|
||||
const response = await sendCatch('filter-create', {
|
||||
@@ -132,30 +125,32 @@ export function SavedFilterMenuButton({
|
||||
onReloadSavedFilter({
|
||||
...newSavedFilter,
|
||||
id: response.data,
|
||||
tombstone: false,
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
if (!filter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedFilter = {
|
||||
...filter,
|
||||
...dirtyFilter,
|
||||
name,
|
||||
};
|
||||
|
||||
const response = await sendCatch('filter-update', {
|
||||
state: updatedFilter,
|
||||
filters: [...savedFilters],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setErr(response.error.message);
|
||||
setNameOpen(true);
|
||||
} else {
|
||||
setNameOpen(false);
|
||||
onReloadSavedFilter(updatedFilter);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFilter = {
|
||||
conditions: filterId?.conditions,
|
||||
conditionsOp: filterId?.conditionsOp,
|
||||
id: filterId?.id,
|
||||
name,
|
||||
};
|
||||
|
||||
const response = await sendCatch('filter-update', {
|
||||
state: updatedFilter,
|
||||
filters: [...savedFilters],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setErr(response.error.message);
|
||||
setNameOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setNameOpen(false);
|
||||
onReloadSavedFilter(updatedFilter);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -178,9 +173,9 @@ export function SavedFilterMenuButton({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{!filterId?.id ? t('Unsaved filter') : filterId?.name}
|
||||
{!filter?.id ? t('Unsaved filter') : filter?.name}
|
||||
</Text>
|
||||
{filterId?.id && filterId?.status !== 'saved' && (
|
||||
{filter?.id && !!dirtyFilter && (
|
||||
<Text>
|
||||
<Trans>(modified)</Trans>
|
||||
</Text>
|
||||
@@ -196,7 +191,8 @@ export function SavedFilterMenuButton({
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
<FilterMenu
|
||||
filterId={filterId}
|
||||
filter={filter}
|
||||
dirtyFilter={dirtyFilter}
|
||||
onFilterMenuSelect={onFilterMenuSelect}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'loot-core/client/actions';
|
||||
import { amountToInteger } from 'loot-core/src/shared/util';
|
||||
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { useDateFormat } from '../../../hooks/useDateFormat';
|
||||
import { useSyncedPrefs } from '../../../hooks/useSyncedPrefs';
|
||||
import { useDispatch } from '../../../redux';
|
||||
@@ -157,7 +158,8 @@ export function ImportTransactionsModal({ options }) {
|
||||
const [flipAmount, setFlipAmount] = useState(false);
|
||||
const [multiplierEnabled, setMultiplierEnabled] = useState(false);
|
||||
const [reconcile, setReconcile] = useState(true);
|
||||
const { accountId, categories, onImported } = options;
|
||||
const { accountId, onImported } = options;
|
||||
const { list: categories } = useCategories();
|
||||
|
||||
// This cannot be set after parsing the file, because changing it
|
||||
// requires re-parsing the file. This is different from the other
|
||||
@@ -241,7 +243,7 @@ export function ImportTransactionsModal({ options }) {
|
||||
break;
|
||||
}
|
||||
|
||||
const category_id = parseCategoryFields(trans, categories.list);
|
||||
const category_id = parseCategoryFields(trans, categories);
|
||||
if (category_id != null) {
|
||||
trans.category = category_id;
|
||||
}
|
||||
@@ -295,7 +297,7 @@ export function ImportTransactionsModal({ options }) {
|
||||
// add the updated existing transaction in the list, with the
|
||||
// isMatchedTransaction flag to identify it in display and not send it again
|
||||
existing_trx.isMatchedTransaction = true;
|
||||
existing_trx.category = categories.list.find(
|
||||
existing_trx.category = categories.find(
|
||||
cat => cat.id === existing_trx.category,
|
||||
)?.name;
|
||||
// add parent transaction attribute to mimic behaviour
|
||||
@@ -310,7 +312,7 @@ export function ImportTransactionsModal({ options }) {
|
||||
return next;
|
||||
}, []);
|
||||
},
|
||||
[accountId, categories.list, clearOnImport, dispatch],
|
||||
[accountId, categories, clearOnImport, dispatch],
|
||||
);
|
||||
|
||||
const parse = useCallback(
|
||||
@@ -584,7 +586,7 @@ export function ImportTransactionsModal({ options }) {
|
||||
break;
|
||||
}
|
||||
|
||||
const category_id = parseCategoryFields(trans, categories.list);
|
||||
const category_id = parseCategoryFields(trans, categories);
|
||||
trans.category = category_id;
|
||||
|
||||
const {
|
||||
@@ -784,7 +786,7 @@ export function ImportTransactionsModal({ options }) {
|
||||
outValue={outValue}
|
||||
flipAmount={flipAmount}
|
||||
multiplierAmount={multiplierAmount}
|
||||
categories={categories.list}
|
||||
categories={categories}
|
||||
onCheckTransaction={onCheckTransaction}
|
||||
reconcile={reconcile}
|
||||
/>
|
||||
|
||||
@@ -35,7 +35,7 @@ type HeaderProps = {
|
||||
mode: TimeFrame['mode'],
|
||||
) => void;
|
||||
filters?: RuleConditionEntity[];
|
||||
conditionsOp: 'and' | 'or';
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
onApply?: (conditions: RuleConditionEntity) => void;
|
||||
onUpdateFilter: ComponentProps<typeof AppliedFilters>['onUpdate'];
|
||||
onDeleteFilter: ComponentProps<typeof AppliedFilters>['onDelete'];
|
||||
|
||||
@@ -38,7 +38,6 @@ import { useMergedRefs } from '../../../hooks/useMergedRefs';
|
||||
import { useNavigate } from '../../../hooks/useNavigate';
|
||||
import { usePayees } from '../../../hooks/usePayees';
|
||||
import { useResizeObserver } from '../../../hooks/useResizeObserver';
|
||||
import { SelectedProviderWithItems } from '../../../hooks/useSelected';
|
||||
import { SplitsExpandedProvider } from '../../../hooks/useSplitsExpanded';
|
||||
import { useSyncedPref } from '../../../hooks/useSyncedPref';
|
||||
import {
|
||||
@@ -563,141 +562,129 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={[]}
|
||||
fetchAllIds={async () => []}
|
||||
registerDispatch={() => {}}
|
||||
selectAllFilter={(item: TransactionEntity) =>
|
||||
!item._unmatched && !item.is_parent
|
||||
}
|
||||
>
|
||||
<SchedulesProvider query={undefined}>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
overflow: isNarrowWidth ? 'auto' : 'hidden',
|
||||
}}
|
||||
ref={table}
|
||||
>
|
||||
{!isNarrowWidth ? (
|
||||
<SplitsExpandedProvider initialMode="collapse">
|
||||
<TransactionList
|
||||
headerContent={undefined}
|
||||
tableRef={table}
|
||||
account={undefined}
|
||||
transactions={transactionsGrouped}
|
||||
allTransactions={allTransactions}
|
||||
loadMoreTransactions={loadMoreTransactions}
|
||||
accounts={accounts}
|
||||
category={undefined}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
balances={null}
|
||||
showBalances={false}
|
||||
showReconciled={true}
|
||||
showCleared={false}
|
||||
showAccount={true}
|
||||
isAdding={false}
|
||||
isNew={() => false}
|
||||
isMatched={() => false}
|
||||
isFiltered={() => true}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={false}
|
||||
addNotification={addNotification}
|
||||
renderEmpty={() => (
|
||||
<View
|
||||
style={{
|
||||
color: theme.tableText,
|
||||
marginTop: 20,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
<Trans>No transactions</Trans>
|
||||
</View>
|
||||
)}
|
||||
onSort={onSort}
|
||||
sortField={sortField}
|
||||
ascDesc={ascDesc}
|
||||
onChange={() => {}}
|
||||
onRefetch={() => setDirty(true)}
|
||||
onCloseAddTransaction={() => {}}
|
||||
onCreatePayee={() => {}}
|
||||
onApplyFilter={() => {}}
|
||||
onBatchDelete={() => {}}
|
||||
onBatchDuplicate={() => {}}
|
||||
onBatchLinkSchedule={() => {}}
|
||||
onBatchUnlinkSchedule={() => {}}
|
||||
onCreateRule={() => {}}
|
||||
onScheduleAction={() => {}}
|
||||
onMakeAsNonSplitTransactions={() => {}}
|
||||
showSelection={false}
|
||||
allowSplitTransaction={false}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
) : (
|
||||
<animated.div
|
||||
{...bind()}
|
||||
style={{
|
||||
y,
|
||||
touchAction: 'pan-x',
|
||||
backgroundColor: theme.mobileNavBackground,
|
||||
borderTop: `1px solid ${theme.menuBorder}`,
|
||||
...styles.shadow,
|
||||
height: totalHeight + CHEVRON_HEIGHT,
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
bottom: 0,
|
||||
display: isNarrowWidth ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
<SchedulesProvider query={undefined}>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
overflow: isNarrowWidth ? 'auto' : 'hidden',
|
||||
}}
|
||||
ref={table}
|
||||
>
|
||||
{!isNarrowWidth ? (
|
||||
<SplitsExpandedProvider initialMode="collapse">
|
||||
<TransactionList
|
||||
tableRef={table}
|
||||
account={undefined}
|
||||
transactions={transactionsGrouped}
|
||||
allTransactions={allTransactions}
|
||||
loadMoreTransactions={loadMoreTransactions}
|
||||
accounts={accounts}
|
||||
category={undefined}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
balances={null}
|
||||
showBalances={false}
|
||||
showReconciled={true}
|
||||
showCleared={false}
|
||||
showAccount={true}
|
||||
isAdding={false}
|
||||
isNew={() => false}
|
||||
isMatched={() => false}
|
||||
isFiltered={() => true}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={false}
|
||||
renderEmpty={() => (
|
||||
<View
|
||||
style={{
|
||||
color: theme.tableText,
|
||||
marginTop: 20,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
<Trans>No transactions</Trans>
|
||||
</View>
|
||||
)}
|
||||
onSort={onSort}
|
||||
sortField={sortField}
|
||||
ascDesc={ascDesc}
|
||||
onChange={() => {}}
|
||||
onRefetch={() => setDirty(true)}
|
||||
onCloseAddTransaction={() => {}}
|
||||
onCreatePayee={() => {}}
|
||||
onApplyFilter={() => {}}
|
||||
onBatchDelete={() => {}}
|
||||
onBatchDuplicate={() => {}}
|
||||
onBatchLinkSchedule={() => {}}
|
||||
onBatchUnlinkSchedule={() => {}}
|
||||
onCreateRule={() => {}}
|
||||
onScheduleAction={() => {}}
|
||||
onMakeAsNonSplitTransactions={() => {}}
|
||||
showSelection={false}
|
||||
allowSplitTransaction={false}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
) : (
|
||||
<animated.div
|
||||
{...bind()}
|
||||
style={{
|
||||
y,
|
||||
touchAction: 'pan-x',
|
||||
backgroundColor: theme.mobileNavBackground,
|
||||
borderTop: `1px solid ${theme.menuBorder}`,
|
||||
...styles.shadow,
|
||||
height: totalHeight + CHEVRON_HEIGHT,
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
bottom: 0,
|
||||
display: isNarrowWidth ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
!mobileTransactionsOpen
|
||||
? open({ canceled: false })
|
||||
: close()
|
||||
}
|
||||
className={css({
|
||||
color: theme.pageTextSubdued,
|
||||
height: 42,
|
||||
'&[data-pressed]': { backgroundColor: 'transparent' },
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() =>
|
||||
!mobileTransactionsOpen
|
||||
? open({ canceled: false })
|
||||
: close()
|
||||
}
|
||||
className={css({
|
||||
color: theme.pageTextSubdued,
|
||||
height: 42,
|
||||
'&[data-pressed]': { backgroundColor: 'transparent' },
|
||||
})}
|
||||
>
|
||||
{!mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronUp width={16} height={16} />
|
||||
<Trans>Show transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
{mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronDown width={16} height={16} />
|
||||
<Trans>Hide transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<View
|
||||
style={{ height: '100%', width: '100%', overflow: 'auto' }}
|
||||
>
|
||||
<TransactionListMobile
|
||||
isLoading={false}
|
||||
onLoadMore={loadMoreTransactions}
|
||||
transactions={allTransactions}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
isLoadingMore={false}
|
||||
/>
|
||||
</View>
|
||||
</animated.div>
|
||||
)}
|
||||
</View>
|
||||
</SchedulesProvider>
|
||||
</SelectedProviderWithItems>
|
||||
{!mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronUp width={16} height={16} />
|
||||
<Trans>Show transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
{mobileTransactionsOpen && (
|
||||
<>
|
||||
<SvgCheveronDown width={16} height={16} />
|
||||
<Trans>Hide transactions</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<View
|
||||
style={{ height: '100%', width: '100%', overflow: 'auto' }}
|
||||
>
|
||||
<TransactionListMobile
|
||||
isLoading={false}
|
||||
onLoadMore={loadMoreTransactions}
|
||||
transactions={allTransactions}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
isLoadingMore={false}
|
||||
/>
|
||||
</View>
|
||||
</animated.div>
|
||||
)}
|
||||
</View>
|
||||
</SchedulesProvider>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ export function simpleCashFlow(
|
||||
startMonth: string,
|
||||
endMonth: string,
|
||||
conditions: RuleConditionEntity[] = [],
|
||||
conditionsOp: 'and' | 'or' = 'and',
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'] = 'and',
|
||||
) {
|
||||
const start = monthUtils.firstDayOfMonth(startMonth);
|
||||
const end = monthUtils.lastDayOfMonth(endMonth);
|
||||
@@ -71,7 +71,7 @@ export function cashFlowByDate(
|
||||
endMonth: string,
|
||||
isConcise: boolean,
|
||||
conditions: RuleConditionEntity[] = [],
|
||||
conditionsOp: 'and' | 'or',
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'],
|
||||
) {
|
||||
const start = monthUtils.firstDayOfMonth(startMonth);
|
||||
const end = monthUtils.lastDayOfMonth(endMonth);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function createSpreadsheet(
|
||||
end: string,
|
||||
accounts: AccountEntity[],
|
||||
conditions: RuleConditionEntity[] = [],
|
||||
conditionsOp: 'and' | 'or' = 'and',
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'] = 'and',
|
||||
) {
|
||||
return async (
|
||||
spreadsheet: ReturnType<typeof useSpreadsheet>,
|
||||
|
||||
@@ -14,7 +14,7 @@ export function summarySpreadsheet(
|
||||
start: string,
|
||||
end: string,
|
||||
conditions: RuleConditionEntity[] = [],
|
||||
conditionsOp: 'and' | 'or' = 'and',
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'] = 'and',
|
||||
summaryContent: SummaryContent,
|
||||
) {
|
||||
return async (
|
||||
|
||||
@@ -70,6 +70,8 @@ export function Value<T>({
|
||||
return value ? 'true' : 'false';
|
||||
} else {
|
||||
switch (field) {
|
||||
case 'id':
|
||||
return value;
|
||||
case 'amount':
|
||||
return integerToCurrency(value);
|
||||
case 'date':
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { pushModal } from 'loot-core/client/actions';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import { q } from 'loot-core/src/shared/query';
|
||||
import {
|
||||
type ScheduleEntity,
|
||||
type TransactionEntity,
|
||||
@@ -37,6 +37,7 @@ export function ScheduleLink({
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [filter, setFilter] = useState(accountName || '');
|
||||
|
||||
const schedulesQuery = useMemo(
|
||||
() => q('schedules').filter({ completed: false }).select('*'),
|
||||
[],
|
||||
@@ -47,25 +48,26 @@ export function ScheduleLink({
|
||||
statuses,
|
||||
} = useSchedules({ query: schedulesQuery });
|
||||
|
||||
const searchInput = useRef(null);
|
||||
const onSelect = useCallback(
|
||||
async (scheduleId: string) => {
|
||||
if (ids?.length > 0) {
|
||||
await send('transactions-batch-update', {
|
||||
updated: ids.map(id => ({ id, schedule: scheduleId })),
|
||||
});
|
||||
onScheduleLinked?.(schedules.find(s => s.id === scheduleId));
|
||||
}
|
||||
},
|
||||
[ids, onScheduleLinked, schedules],
|
||||
);
|
||||
|
||||
async function onSelect(scheduleId: string) {
|
||||
if (ids?.length > 0) {
|
||||
await send('transactions-batch-update', {
|
||||
updated: ids.map(id => ({ id, schedule: scheduleId })),
|
||||
});
|
||||
onScheduleLinked?.(schedules.find(s => s.id === scheduleId));
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
const onCreate = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal('schedule-edit', {
|
||||
id: null,
|
||||
transaction: getTransaction(ids[0]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, getTransaction, ids]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -98,7 +100,6 @@ export function ScheduleLink({
|
||||
</Text>
|
||||
<InitialFocus>
|
||||
<Search
|
||||
inputRef={searchInput}
|
||||
isInModal
|
||||
width={300}
|
||||
placeholder={t('Filter schedules…')}
|
||||
|
||||
@@ -290,7 +290,7 @@ function ScheduleRow({
|
||||
}
|
||||
|
||||
export function SchedulesTable({
|
||||
isLoading,
|
||||
isLoading = false,
|
||||
schedules,
|
||||
statuses,
|
||||
filter,
|
||||
|
||||
@@ -72,13 +72,16 @@ export type Spreadsheets = {
|
||||
goal: number;
|
||||
'long-goal': number;
|
||||
};
|
||||
[`balance`]: {
|
||||
balance: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
'filtered-balance': number;
|
||||
|
||||
// Balance fields
|
||||
[key: `balance-query-${string}`]: number;
|
||||
[key: `balance-query-${string}-cleared`]: number;
|
||||
[key: `balance-query-${string}-uncleared`]: number;
|
||||
[key: `selected-transactions-${string}`]: Array<{ id: string }>;
|
||||
[key: `selected-balance-${string}`]: number;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ import { SvgDelete, SvgExpandArrow } from '../icons/v0';
|
||||
import { SvgCheckmark } from '../icons/v1';
|
||||
import { styles, theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
import { ButtonWithLoading } from './common/Button2';
|
||||
import { Input } from './common/Input';
|
||||
import { Menu, type MenuItem } from './common/Menu';
|
||||
import { Popover } from './common/Popover';
|
||||
@@ -815,6 +815,7 @@ type SelectedItemsButtonProps<Name extends string> = {
|
||||
name: ((count: number) => string) | string;
|
||||
items: MenuItem<Name>[];
|
||||
onSelect: (name: Name, items: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function SelectedItemsButton<Name extends string>({
|
||||
@@ -822,6 +823,7 @@ export function SelectedItemsButton<Name extends string>({
|
||||
name,
|
||||
items,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
}: SelectedItemsButtonProps<Name>) {
|
||||
const selectedItems = useSelectedItems();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -836,7 +838,8 @@ export function SelectedItemsButton<Name extends string>({
|
||||
|
||||
return (
|
||||
<View style={{ marginLeft: 10, flexShrink: 0 }}>
|
||||
<Button
|
||||
<ButtonWithLoading
|
||||
isLoading={isLoading}
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
style={{ color: theme.pageTextPositive }}
|
||||
@@ -849,7 +852,7 @@ export function SelectedItemsButton<Name extends string>({
|
||||
style={{ marginRight: 5, color: theme.pageText }}
|
||||
/>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</ButtonWithLoading>
|
||||
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
|
||||
@@ -39,6 +39,7 @@ type SelectedTransactionsButtonProps = {
|
||||
showMakeTransfer: boolean;
|
||||
onMakeAsSplitTransaction: (selectedIds: string[]) => void;
|
||||
onMakeAsNonSplitTransactions: (selectedIds: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function SelectedTransactionsButton({
|
||||
@@ -55,6 +56,7 @@ export function SelectedTransactionsButton({
|
||||
showMakeTransfer,
|
||||
onMakeAsSplitTransaction,
|
||||
onMakeAsNonSplitTransactions,
|
||||
isLoading = false,
|
||||
}: SelectedTransactionsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -206,6 +208,7 @@ export function SelectedTransactionsButton({
|
||||
return (
|
||||
<SelectedItemsButton
|
||||
id="transactions"
|
||||
isLoading={isLoading}
|
||||
name={count => t('{{count}} transactions', { count })}
|
||||
// @ts-expect-error fix me
|
||||
items={[
|
||||
|
||||
@@ -57,6 +57,7 @@ async function saveDiffAndApply(diff, changes, onChange) {
|
||||
}
|
||||
|
||||
export function TransactionList({
|
||||
isLoading = false,
|
||||
tableRef,
|
||||
transactions,
|
||||
allTransactions,
|
||||
@@ -71,14 +72,12 @@ export function TransactionList({
|
||||
showReconciled,
|
||||
showCleared,
|
||||
showAccount,
|
||||
headerContent,
|
||||
isAdding,
|
||||
isNew,
|
||||
isMatched,
|
||||
isFiltered,
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
addNotification,
|
||||
renderEmpty,
|
||||
onSort,
|
||||
sortField,
|
||||
@@ -110,7 +109,6 @@ export function TransactionList({
|
||||
newTransactions = realizeTempTransactions(newTransactions);
|
||||
|
||||
await saveDiff({ added: newTransactions });
|
||||
onRefetch();
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(async transaction => {
|
||||
@@ -208,6 +206,7 @@ export function TransactionList({
|
||||
|
||||
return (
|
||||
<TransactionTable
|
||||
isLoading={isLoading}
|
||||
ref={tableRef}
|
||||
transactions={allTransactions}
|
||||
loadMoreTransactions={loadMoreTransactions}
|
||||
@@ -228,8 +227,6 @@ export function TransactionList({
|
||||
isFiltered={isFiltered}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
addNotification={addNotification}
|
||||
headerContent={headerContent}
|
||||
renderEmpty={renderEmpty}
|
||||
onSave={onSave}
|
||||
onApplyRules={onApplyRules}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, {
|
||||
createElement,
|
||||
createRef,
|
||||
forwardRef,
|
||||
memo,
|
||||
useState,
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
isValid as isDateValid,
|
||||
} from 'date-fns';
|
||||
|
||||
import { pushModal } from 'loot-core/client/actions';
|
||||
import { addNotification, pushModal } from 'loot-core/client/actions';
|
||||
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
|
||||
import {
|
||||
getAccountsById,
|
||||
@@ -1831,6 +1830,7 @@ function NewTransaction({
|
||||
}
|
||||
|
||||
function TransactionTableInner({
|
||||
isLoading,
|
||||
tableNavigator,
|
||||
tableRef,
|
||||
listContainerRef,
|
||||
@@ -1840,7 +1840,7 @@ function TransactionTableInner({
|
||||
onScroll,
|
||||
...props
|
||||
}) {
|
||||
const containerRef = createRef();
|
||||
const containerRef = useRef();
|
||||
const isAddingPrev = usePrevious(props.isAdding);
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
|
||||
@@ -2070,6 +2070,7 @@ function TransactionTableInner({
|
||||
data-testid="transaction-table"
|
||||
>
|
||||
<Table
|
||||
loading={isLoading}
|
||||
navigator={tableNavigator}
|
||||
ref={tableRef}
|
||||
listContainerRef={listContainerRef}
|
||||
@@ -2103,6 +2104,7 @@ function TransactionTableInner({
|
||||
}
|
||||
|
||||
export const TransactionTable = forwardRef((props, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
const [newTransactions, setNewTransactions] = useState(null);
|
||||
const [prevIsAdding, setPrevIsAdding] = useState(false);
|
||||
const splitsExpanded = useSplitsExpanded();
|
||||
@@ -2235,10 +2237,12 @@ export const TransactionTable = forwardRef((props, ref) => {
|
||||
useEffect(() => {
|
||||
if (shouldAdd.current) {
|
||||
if (newTransactions[0].account == null) {
|
||||
props.addNotification({
|
||||
type: 'error',
|
||||
message: 'Account is a required field',
|
||||
});
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: 'Account is a required field',
|
||||
}),
|
||||
);
|
||||
newNavigator.onEdit('temp', 'account');
|
||||
} else {
|
||||
const transactions = latestState.current.newTransactions;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { integerToCurrency } from 'loot-core/src/shared/util';
|
||||
|
||||
import { AuthProvider } from '../../auth/AuthProvider';
|
||||
import { SelectedProviderWithItems } from '../../hooks/useSelected';
|
||||
import { SelectedProvider, useSelected } from '../../hooks/useSelected';
|
||||
import { SplitsExpandedProvider } from '../../hooks/useSplitsExpanded';
|
||||
import { TestProvider } from '../../redux/mock';
|
||||
import { ResponsiveProvider } from '../responsive/ResponsiveProvider';
|
||||
@@ -110,6 +110,20 @@ function generateTransactions(count, splitAtIndexes = [], showError = false) {
|
||||
return transactions;
|
||||
}
|
||||
|
||||
// This is needed because useSelected needs redux access.
|
||||
// This provider needs to be a child of the redux TestProvider.
|
||||
function TestSelectedProvider({ transactions, children }) {
|
||||
const selectedInst = useSelected('transactions', transactions);
|
||||
return (
|
||||
<SelectedProvider
|
||||
instance={selectedInst}
|
||||
fetchAllIds={() => transactions.map(t => t.id)}
|
||||
>
|
||||
{children}
|
||||
</SelectedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveTransactionTable(props) {
|
||||
const [transactions, setTransactions] = useState(props.transactions);
|
||||
|
||||
@@ -152,11 +166,7 @@ function LiveTransactionTable(props) {
|
||||
<AuthProvider>
|
||||
<SpreadsheetProvider>
|
||||
<SchedulesProvider>
|
||||
<SelectedProviderWithItems
|
||||
name="transactions"
|
||||
items={transactions}
|
||||
fetchAllIds={() => transactions.map(t => t.id)}
|
||||
>
|
||||
<TestSelectedProvider transactions={transactions}>
|
||||
<SplitsExpandedProvider>
|
||||
<TransactionTable
|
||||
{...props}
|
||||
@@ -164,17 +174,14 @@ function LiveTransactionTable(props) {
|
||||
loadMoreTransactions={() => {}}
|
||||
commonPayees={[]}
|
||||
payees={payees}
|
||||
addNotification={n => console.log(n)}
|
||||
onSave={onSave}
|
||||
onSplit={onSplit}
|
||||
onAdd={onAdd}
|
||||
onAddSplit={onAddSplit}
|
||||
onCreatePayee={onCreatePayee}
|
||||
showSelection={true}
|
||||
allowSplitTransaction={true}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SelectedProviderWithItems>
|
||||
</TestSelectedProvider>
|
||||
</SchedulesProvider>
|
||||
</SpreadsheetProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -25,6 +25,7 @@ export type BoundActions = {
|
||||
* @deprecated please use actions directly with `useAppDispatch`
|
||||
* @see https://github.com/reduxjs/react-redux/issues/1252#issuecomment-488160930
|
||||
**/
|
||||
|
||||
export function useActions() {
|
||||
const dispatch = useDispatch();
|
||||
return useMemo(() => {
|
||||
|
||||
@@ -4,19 +4,22 @@ import { type RuleConditionEntity } from 'loot-core/types/models/rule';
|
||||
|
||||
export function useFilters<T extends RuleConditionEntity>(
|
||||
initialConditions: T[] = [],
|
||||
initialConditionsOp: 'and' | 'or' = 'and',
|
||||
initialConditionsOp: RuleConditionEntity['conditionsOp'] = 'and',
|
||||
) {
|
||||
const [conditions, setConditions] = useState<T[]>(initialConditions);
|
||||
const [conditionsOp, setConditionsOp] = useState<'and' | 'or'>(
|
||||
initialConditionsOp,
|
||||
);
|
||||
const [conditionsOp, setConditionsOp] =
|
||||
useState<RuleConditionEntity['conditionsOp']>(initialConditionsOp);
|
||||
const [saved, setSaved] = useState<T[] | null>(null);
|
||||
|
||||
const onApply = useCallback(
|
||||
(
|
||||
conditionsOrSavedFilter:
|
||||
| null
|
||||
| { conditions: T[]; conditionsOp: 'and' | 'or'; id: T[] | null }
|
||||
| {
|
||||
conditions: T[];
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
id: T[] | null;
|
||||
}
|
||||
| T,
|
||||
) => {
|
||||
if (conditionsOrSavedFilter === null) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
@@ -47,12 +46,12 @@ type SelectAllAction = {
|
||||
isRangeSelect?: boolean;
|
||||
};
|
||||
|
||||
export type Actions = SelectAction | SelectNoneAction | SelectAllAction;
|
||||
type Actions = SelectAction | SelectNoneAction | SelectAllAction;
|
||||
|
||||
export function useSelected<T extends Item>(
|
||||
name: string,
|
||||
items: T[],
|
||||
initialSelectedIds: string[],
|
||||
items: readonly T[],
|
||||
initialSelectedIds: readonly string[],
|
||||
selectAllFilter?: (item: T) => boolean,
|
||||
) {
|
||||
const [state, dispatch] = useReducer(
|
||||
@@ -304,42 +303,3 @@ export function SelectedProvider<T extends Item>({
|
||||
</SelectedItems.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type SelectedProviderWithItemsProps<T extends Item> = {
|
||||
name: string;
|
||||
items: T[];
|
||||
initialSelectedIds?: string[];
|
||||
fetchAllIds: () => Promise<string[]>;
|
||||
registerDispatch?: (dispatch: Dispatch<Actions>) => void;
|
||||
selectAllFilter?: (item: T) => boolean;
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
// This can be helpful in class components if you cannot use the
|
||||
// custom hook
|
||||
export function SelectedProviderWithItems<T extends Item>({
|
||||
name,
|
||||
items,
|
||||
initialSelectedIds = [],
|
||||
fetchAllIds,
|
||||
registerDispatch,
|
||||
selectAllFilter,
|
||||
children,
|
||||
}: SelectedProviderWithItemsProps<T>) {
|
||||
const selected = useSelected<T>(
|
||||
name,
|
||||
items,
|
||||
initialSelectedIds,
|
||||
selectAllFilter,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerDispatch?.(selected.dispatch);
|
||||
}, [registerDispatch]);
|
||||
|
||||
return (
|
||||
<SelectedProvider<T> instance={selected} fetchAllIds={fetchAllIds}>
|
||||
{children}
|
||||
</SelectedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function useSchedules({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsLoading(query !== null);
|
||||
|
||||
scheduleQueryRef.current = liveQuery<ScheduleEntity>(query, {
|
||||
onData: async schedules => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { type Query } from '../../shared/query';
|
||||
import { getScheduledAmount } from '../../shared/schedules';
|
||||
import { ungroupTransactions } from '../../shared/transactions';
|
||||
import {
|
||||
type TransactionFilterEntity,
|
||||
type RuleConditionEntity,
|
||||
type ScheduleEntity,
|
||||
type TransactionEntity,
|
||||
} from '../../types/models';
|
||||
@@ -233,20 +235,26 @@ export function useTransactionsSearch({
|
||||
}: UseTransactionsSearchProps): UseTransactionsSearchResult {
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const updateQueryRef = useRef(updateQuery);
|
||||
updateQueryRef.current = updateQuery;
|
||||
|
||||
const resetQueryRef = useRef(resetQuery);
|
||||
resetQueryRef.current = resetQuery;
|
||||
|
||||
const updateSearchQuery = useMemo(
|
||||
() =>
|
||||
debounce((searchText: string) => {
|
||||
if (searchText === '') {
|
||||
resetQuery();
|
||||
resetQueryRef.current?.();
|
||||
setIsSearching(false);
|
||||
} else if (searchText) {
|
||||
updateQuery(previousQuery =>
|
||||
updateQueryRef.current?.(previousQuery =>
|
||||
queries.transactionsSearch(previousQuery, searchText, dateFormat),
|
||||
);
|
||||
setIsSearching(true);
|
||||
}
|
||||
}, delayMs),
|
||||
[dateFormat, delayMs, resetQuery, updateQuery],
|
||||
[dateFormat, delayMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -259,6 +267,206 @@ export function useTransactionsSearch({
|
||||
};
|
||||
}
|
||||
|
||||
type UseTransactionsFilterProps = {
|
||||
updateQuery?: (updateFn: (filterQuery: Query) => Query) => void;
|
||||
resetQuery?: () => void;
|
||||
initialConditions?: RuleConditionEntity[];
|
||||
initialConditionsOp?: RuleConditionEntity['conditionsOp'];
|
||||
};
|
||||
|
||||
type UseTransactionsFilterResult = {
|
||||
isFiltered: boolean;
|
||||
activeFilter?: TransactionFilterEntity;
|
||||
dirtyFilter?: TransactionFilterEntity;
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
updateConditionsOp: (op: RuleConditionEntity['conditionsOp']) => void;
|
||||
conditions: readonly RuleConditionEntity[];
|
||||
updateConditions: (
|
||||
conditions:
|
||||
| RuleConditionEntity[]
|
||||
| ((conditions: RuleConditionEntity[]) => RuleConditionEntity[]),
|
||||
) => void;
|
||||
clear: () => void;
|
||||
reset: () => void;
|
||||
applyFilter: (
|
||||
savedFilter: TransactionFilterEntity,
|
||||
clearConditions?: boolean,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function useTransactionsFilter({
|
||||
updateQuery,
|
||||
resetQuery,
|
||||
initialConditions = [],
|
||||
initialConditionsOp = 'and',
|
||||
}: UseTransactionsFilterProps): UseTransactionsFilterResult {
|
||||
const [isFiltered, setIsFiltered] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<
|
||||
TransactionFilterEntity | undefined
|
||||
>(undefined);
|
||||
const [dirtyFilter, setDirtyFilter] = useState<
|
||||
TransactionFilterEntity | undefined
|
||||
>(undefined);
|
||||
const [conditions, setConditions] =
|
||||
useState<RuleConditionEntity[]>(initialConditions);
|
||||
const [conditionsOp, setConditionsOp] =
|
||||
useState<RuleConditionEntity['conditionsOp']>(initialConditionsOp);
|
||||
|
||||
const updateQueryRef = useRef(updateQuery);
|
||||
updateQueryRef.current = updateQuery;
|
||||
|
||||
const resetQueryRef = useRef(resetQuery);
|
||||
resetQueryRef.current = resetQuery;
|
||||
|
||||
const updateQueryFilter = useCallback(
|
||||
async (conditions: RuleConditionEntity[]) => {
|
||||
const { filters } = await send('make-filters-from-conditions', {
|
||||
conditions,
|
||||
});
|
||||
|
||||
const filter = {
|
||||
[conditionsOp === 'and' ? '$and' : '$or']: filters,
|
||||
};
|
||||
|
||||
updateQueryRef.current?.(previousQuery =>
|
||||
previousQuery.unfilter().filter(filter),
|
||||
);
|
||||
},
|
||||
[conditionsOp],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false;
|
||||
|
||||
if (conditions.length === 0) {
|
||||
resetQueryRef.current?.();
|
||||
setIsFiltered(false);
|
||||
} else {
|
||||
updateQueryFilter(conditions).then(() => {
|
||||
if (!isUnmounted) {
|
||||
setIsFiltered(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
};
|
||||
}, [conditions, updateQueryFilter]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setConditionsOp(initialConditionsOp);
|
||||
setConditions([]);
|
||||
setActiveFilter(undefined);
|
||||
setDirtyFilter(undefined);
|
||||
}, [initialConditionsOp]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setConditionsOp(initialConditionsOp);
|
||||
setConditions(initialConditions);
|
||||
setActiveFilter(undefined);
|
||||
setDirtyFilter(undefined);
|
||||
}, [initialConditions, initialConditionsOp]);
|
||||
|
||||
const applyFilter = useCallback(
|
||||
(savedFilter: TransactionFilterEntity, clearConditions = false) => {
|
||||
setActiveFilter(savedFilter);
|
||||
setDirtyFilter(undefined);
|
||||
setConditionsOp(savedFilter.conditionsOp);
|
||||
if (clearConditions) {
|
||||
setConditions(savedFilter.conditions);
|
||||
} else {
|
||||
setConditions(previousConditions => [
|
||||
...previousConditions,
|
||||
...savedFilter.conditions,
|
||||
]);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateConditionsOp = useCallback(
|
||||
(op: RuleConditionEntity['conditionsOp'] = 'and') => {
|
||||
setConditionsOp(op);
|
||||
if (activeFilter && activeFilter.conditionsOp !== op) {
|
||||
setDirtyFilter({ ...activeFilter, conditionsOp: op });
|
||||
}
|
||||
},
|
||||
[activeFilter],
|
||||
);
|
||||
|
||||
const updateActiveFilterIfNeeded = useCallback(
|
||||
({
|
||||
activeFilter,
|
||||
conditions,
|
||||
}: {
|
||||
activeFilter?: TransactionFilterEntity;
|
||||
conditions: RuleConditionEntity[];
|
||||
}) => {
|
||||
if (activeFilter) {
|
||||
if (conditions.length === 0) {
|
||||
setActiveFilter(undefined);
|
||||
setDirtyFilter(undefined);
|
||||
} else if (activeFilter.conditions !== conditions) {
|
||||
setDirtyFilter({ ...activeFilter, conditions });
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateConditions = useCallback(
|
||||
(conditions: Parameters<typeof setConditions>[0]) => {
|
||||
setConditions(previousConditions => {
|
||||
const maybeNewConditions =
|
||||
typeof conditions === 'function'
|
||||
? (conditions(previousConditions) ?? [])
|
||||
: (conditions ?? []);
|
||||
|
||||
updateActiveFilterIfNeeded({
|
||||
activeFilter,
|
||||
conditions: maybeNewConditions,
|
||||
});
|
||||
|
||||
return maybeNewConditions;
|
||||
});
|
||||
},
|
||||
[activeFilter, updateActiveFilterIfNeeded],
|
||||
);
|
||||
|
||||
return {
|
||||
isFiltered,
|
||||
activeFilter,
|
||||
dirtyFilter,
|
||||
applyFilter,
|
||||
conditionsOp,
|
||||
updateConditionsOp,
|
||||
conditions,
|
||||
updateConditions,
|
||||
clear,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export type TransactionFilter = RuleConditionEntity | TransactionFilterEntity;
|
||||
|
||||
export function isTransactionFilterEntity(
|
||||
filter: TransactionFilter,
|
||||
): filter is TransactionFilterEntity {
|
||||
return 'id' in filter;
|
||||
}
|
||||
|
||||
export function toFilterConditions(
|
||||
filter: TransactionFilter,
|
||||
): RuleConditionEntity[] {
|
||||
if (isTransactionFilterEntity(filter)) {
|
||||
// This is a saved transaction filter.
|
||||
return filter.conditions;
|
||||
} else {
|
||||
// This is a rule condition.
|
||||
return [filter];
|
||||
}
|
||||
}
|
||||
|
||||
function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) {
|
||||
const status = statuses.get(schedule.id);
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect, type DependencyList } from 'react';
|
||||
import { type DependencyList, useState, useEffect, useMemo } from 'react';
|
||||
|
||||
import { type Query } from '../shared/query';
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ type FieldInfoConstraint = Record<
|
||||
>;
|
||||
|
||||
const FIELD_INFO = {
|
||||
id: { type: 'id' },
|
||||
imported_payee: {
|
||||
type: 'string',
|
||||
disallowedOps: new Set(['hasTags']),
|
||||
|
||||
@@ -90,7 +90,10 @@ export function recalculateSplit(trans: TransactionEntity) {
|
||||
} as TransactionEntityWithError;
|
||||
}
|
||||
|
||||
function findParentIndex(transactions: TransactionEntity[], idx: number) {
|
||||
function findParentIndex(
|
||||
transactions: readonly TransactionEntity[],
|
||||
idx: number,
|
||||
) {
|
||||
// This relies on transactions being sorted in a way where parents
|
||||
// are always before children, which is enforced in the db layer.
|
||||
// Walk backwards and find the last parent;
|
||||
@@ -104,7 +107,10 @@ function findParentIndex(transactions: TransactionEntity[], idx: number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSplit(transactions: TransactionEntity[], parentIndex: number) {
|
||||
function getSplit(
|
||||
transactions: readonly TransactionEntity[],
|
||||
parentIndex: number,
|
||||
) {
|
||||
const split = [transactions[parentIndex]];
|
||||
let curr = parentIndex + 1;
|
||||
while (curr < transactions.length && transactions[curr].is_child) {
|
||||
@@ -114,7 +120,9 @@ function getSplit(transactions: TransactionEntity[], parentIndex: number) {
|
||||
return split;
|
||||
}
|
||||
|
||||
export function ungroupTransactions(transactions: TransactionEntity[]) {
|
||||
export function ungroupTransactions(
|
||||
transactions: readonly TransactionEntity[],
|
||||
) {
|
||||
return transactions.reduce<TransactionEntity[]>((list, parent) => {
|
||||
const { subtransactions, ...trans } = parent;
|
||||
const _subtransactions = subtransactions || [];
|
||||
@@ -128,7 +136,7 @@ export function ungroupTransactions(transactions: TransactionEntity[]) {
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function groupTransaction(split: TransactionEntity[]) {
|
||||
export function groupTransaction(split: readonly TransactionEntity[]) {
|
||||
return { ...split[0], subtransactions: split.slice(1) } as TransactionEntity;
|
||||
}
|
||||
|
||||
@@ -152,7 +160,7 @@ export function applyTransactionDiff(
|
||||
}
|
||||
|
||||
function replaceTransactions(
|
||||
transactions: TransactionEntity[],
|
||||
transactions: readonly TransactionEntity[],
|
||||
id: string,
|
||||
func: (
|
||||
transaction: TransactionEntity,
|
||||
@@ -218,7 +226,7 @@ function replaceTransactions(
|
||||
}
|
||||
|
||||
export function addSplitTransaction(
|
||||
transactions: TransactionEntity[],
|
||||
transactions: readonly TransactionEntity[],
|
||||
id: string,
|
||||
) {
|
||||
return replaceTransactions(transactions, id, trans => {
|
||||
@@ -237,7 +245,7 @@ export function addSplitTransaction(
|
||||
}
|
||||
|
||||
export function updateTransaction(
|
||||
transactions: TransactionEntity[],
|
||||
transactions: readonly TransactionEntity[],
|
||||
transaction: TransactionEntity,
|
||||
) {
|
||||
return replaceTransactions(transactions, transaction.id, trans => {
|
||||
@@ -270,7 +278,7 @@ export function updateTransaction(
|
||||
}
|
||||
|
||||
export function deleteTransaction(
|
||||
transactions: TransactionEntity[],
|
||||
transactions: readonly TransactionEntity[],
|
||||
id: string,
|
||||
) {
|
||||
return replaceTransactions(transactions, id, trans => {
|
||||
@@ -295,7 +303,7 @@ export function deleteTransaction(
|
||||
}
|
||||
|
||||
export function splitTransaction(
|
||||
transactions: TransactionEntity[],
|
||||
transactions: readonly TransactionEntity[],
|
||||
id: string,
|
||||
createSubtransactions?: (
|
||||
parentTransaction: TransactionEntity,
|
||||
@@ -323,7 +331,7 @@ export function splitTransaction(
|
||||
}
|
||||
|
||||
export function realizeTempTransactions(
|
||||
transactions: TransactionEntity[],
|
||||
transactions: readonly TransactionEntity[],
|
||||
): TransactionEntity[] {
|
||||
const parent = {
|
||||
...transactions.find(t => !t.is_child),
|
||||
@@ -344,8 +352,8 @@ export function realizeTempTransactions(
|
||||
}
|
||||
|
||||
export function makeAsNonChildTransactions(
|
||||
childTransactionsToUpdate: TransactionEntity[],
|
||||
transactions: TransactionEntity[],
|
||||
childTransactionsToUpdate: readonly TransactionEntity[],
|
||||
transactions: readonly TransactionEntity[],
|
||||
) {
|
||||
const [parentTransaction, ...childTransactions] = transactions;
|
||||
const newNonChildTransactions = childTransactionsToUpdate.map(t =>
|
||||
|
||||
@@ -26,7 +26,7 @@ export type NetWorthWidget = AbstractWidget<
|
||||
{
|
||||
name?: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
conditionsOp?: RuleConditionEntity['conditionsOp'];
|
||||
timeFrame?: TimeFrame;
|
||||
} | null
|
||||
>;
|
||||
@@ -35,7 +35,7 @@ export type CashFlowWidget = AbstractWidget<
|
||||
{
|
||||
name?: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
conditionsOp?: RuleConditionEntity['conditionsOp'];
|
||||
timeFrame?: TimeFrame;
|
||||
showBalance?: boolean;
|
||||
} | null
|
||||
@@ -45,7 +45,7 @@ export type SpendingWidget = AbstractWidget<
|
||||
{
|
||||
name?: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
conditionsOp?: RuleConditionEntity['conditionsOp'];
|
||||
compare?: string;
|
||||
compareTo?: string;
|
||||
isLive?: boolean;
|
||||
@@ -96,7 +96,7 @@ export type SummaryWidget = AbstractWidget<
|
||||
{
|
||||
name?: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp?: 'and' | 'or';
|
||||
conditionsOp?: RuleConditionEntity['conditionsOp'];
|
||||
timeFrame?: TimeFrame;
|
||||
content?: string;
|
||||
} | null
|
||||
@@ -110,7 +110,7 @@ export type BaseSummaryContent = {
|
||||
export type PercentageSummaryContent = {
|
||||
type: 'percentage';
|
||||
divisorConditions: RuleConditionEntity[];
|
||||
divisorConditionsOp: 'and' | 'or';
|
||||
divisorConditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
divisorAllTimeDateRange?: boolean;
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface CustomReportEntity {
|
||||
showUncategorized: boolean;
|
||||
graphType: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditionsOp: 'and' | 'or';
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
data?: GroupedEntity;
|
||||
tombstone?: boolean;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export interface CustomReportData {
|
||||
show_uncategorized: number;
|
||||
graph_type: string;
|
||||
conditions?: RuleConditionEntity[];
|
||||
conditions_op: 'and' | 'or';
|
||||
conditions_op: RuleConditionEntity['conditionsOp'];
|
||||
metadata?: GroupedEntity;
|
||||
interval: string;
|
||||
color_scheme?: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ export type RuleConditionOp =
|
||||
| 'offBudget';
|
||||
|
||||
export type FieldValueTypes = {
|
||||
id: string;
|
||||
account: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
@@ -64,13 +65,13 @@ type BaseConditionEntity<
|
||||
month?: boolean;
|
||||
year?: boolean;
|
||||
};
|
||||
conditionsOp?: string;
|
||||
conditionsOp?: RuleConditionEntity['conditionsOp'];
|
||||
type?: 'id' | 'boolean' | 'date' | 'number' | 'string';
|
||||
customName?: string;
|
||||
queryFilter?: Record<string, { $oneof: string[] }>;
|
||||
};
|
||||
|
||||
export type RuleConditionEntity =
|
||||
| BaseConditionEntity<'id', 'oneOf'>
|
||||
| BaseConditionEntity<
|
||||
'account',
|
||||
| 'is'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type RuleConditionEntity } from './rule';
|
||||
export interface TransactionFilterEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
conditionsOp: 'and' | 'or';
|
||||
conditionsOp: RuleConditionEntity['conditionsOp'];
|
||||
conditions: RuleConditionEntity[];
|
||||
tombstone: boolean;
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/3708.md
Normal file
6
upcoming-release-notes/3708.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Convert Account component to a functional component and use useTransactions hook.
|
||||
Reference in New Issue
Block a user