@tuturuuu/ui 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +5 -5
- package/src/components/ui/custom/settings/task-settings.tsx +76 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +126 -0
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
- package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
- package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
- package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +172 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +196 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +277 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +189 -0
- package/src/components/ui/finance/wallets/query-invalidation.ts +2 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +7 -3
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +10 -0
- package/src/components/ui/finance/wallets/wallets-page.test.tsx +7 -0
- package/src/components/ui/finance/wallets/wallets-page.tsx +21 -5
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +10 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +141 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +208 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +24 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +18 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +411 -323
- package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
- package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
- package/src/hooks/use-task-actions.ts +45 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0](https://github.com/tutur3u/platform/compare/ui-v0.4.1...ui-v0.5.0) (2026-06-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **finance:** add wallet checkpoints ([54f9f29](https://github.com/tutur3u/platform/commit/54f9f29446ff9991e09a68abb258ce66c640b086))
|
|
9
|
+
* **tasks:** add compact task create popover ([6c4b957](https://github.com/tutur3u/platform/commit/6c4b957634136a57e3ceb4ba1fc2f151c8a04314))
|
|
10
|
+
* **tasks:** add task sound effects ([7c4cb06](https://github.com/tutur3u/platform/commit/7c4cb06f8f134db201f54294c3c2641ae9ae5d07))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **finance:** merge transfer rows and sync wallet icons ([084e1ac](https://github.com/tutur3u/platform/commit/084e1ac662a3f41c59cfc54d58fa5897293697d2))
|
|
16
|
+
|
|
3
17
|
## [0.4.1](https://github.com/tutur3u/platform/compare/ui-v0.4.0...ui-v0.4.1) (2026-06-11)
|
|
4
18
|
|
|
5
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -83,13 +83,13 @@
|
|
|
83
83
|
"@tiptap/react": "3.26.0",
|
|
84
84
|
"@tiptap/starter-kit": "3.26.0",
|
|
85
85
|
"@tuturuuu/ai": "0.2.1",
|
|
86
|
-
"@tuturuuu/apis": "0.
|
|
86
|
+
"@tuturuuu/apis": "0.3.0",
|
|
87
87
|
"@tuturuuu/hooks": "0.0.1",
|
|
88
88
|
"@tuturuuu/icons": "0.0.5",
|
|
89
|
-
"@tuturuuu/internal-api": "0.
|
|
89
|
+
"@tuturuuu/internal-api": "0.5.0",
|
|
90
90
|
"@tuturuuu/supabase": "0.3.3",
|
|
91
91
|
"@tuturuuu/trigger": "0.2.0",
|
|
92
|
-
"@tuturuuu/utils": "0.
|
|
92
|
+
"@tuturuuu/utils": "0.6.0",
|
|
93
93
|
"@types/debug": "^4.1.13",
|
|
94
94
|
"browser-image-compression": "^2.0.2",
|
|
95
95
|
"class-variance-authority": "^0.7.1",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"@tanstack/react-table": "^8.21.3",
|
|
149
149
|
"@testing-library/jest-dom": "^6.9.1",
|
|
150
150
|
"@testing-library/react": "^16.3.2",
|
|
151
|
-
"@tuturuuu/types": "0.
|
|
151
|
+
"@tuturuuu/types": "0.7.0",
|
|
152
152
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
153
153
|
"@types/html2canvas": "^1.0.0",
|
|
154
154
|
"@types/lodash": "^4.17.24",
|
|
@@ -21,6 +21,12 @@ import { Switch } from '@tuturuuu/ui/switch';
|
|
|
21
21
|
import { useTranslations } from 'next-intl';
|
|
22
22
|
import { useEffect } from 'react';
|
|
23
23
|
import { TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID } from '../../tu-do/shared/task-due-date-visibility';
|
|
24
|
+
import {
|
|
25
|
+
clampTaskSoundEffectsVolume,
|
|
26
|
+
DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
|
|
27
|
+
TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID,
|
|
28
|
+
TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
|
|
29
|
+
} from '../../tu-do/shared/task-sound-effects';
|
|
24
30
|
|
|
25
31
|
interface TaskSettingsData {
|
|
26
32
|
task_auto_assign_to_self: boolean;
|
|
@@ -74,6 +80,18 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
74
80
|
isLoading: showReviewDueDatesLoading,
|
|
75
81
|
isPending: showReviewDueDatesPending,
|
|
76
82
|
} = useUserBooleanConfig(TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID, false);
|
|
83
|
+
const {
|
|
84
|
+
value: soundEffectsEnabled,
|
|
85
|
+
setValue: setSoundEffectsEnabled,
|
|
86
|
+
isLoading: soundEffectsEnabledLoading,
|
|
87
|
+
isPending: soundEffectsEnabledPending,
|
|
88
|
+
} = useUserBooleanConfig(TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID, true);
|
|
89
|
+
const { data: soundEffectsVolume, isLoading: soundEffectsVolumeLoading } =
|
|
90
|
+
useUserConfig(
|
|
91
|
+
TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
|
|
92
|
+
String(DEFAULT_TASK_SOUND_EFFECTS_VOLUME)
|
|
93
|
+
);
|
|
94
|
+
const updateSoundEffectsVolume = useUpdateUserConfig();
|
|
77
95
|
|
|
78
96
|
const { data: submitShortcut, isLoading: submitShortcutLoading } =
|
|
79
97
|
useUserConfig('TASK_SUBMIT_SHORTCUT', 'enter');
|
|
@@ -128,9 +146,19 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
128
146
|
updateSettings.mutate({ fade_completed_tasks: checked });
|
|
129
147
|
};
|
|
130
148
|
|
|
149
|
+
const handleSoundEffectsVolumeChange = (value: string) => {
|
|
150
|
+
updateSoundEffectsVolume.mutate({
|
|
151
|
+
configId: TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
|
|
152
|
+
value: String(clampTaskSoundEffectsVolume(value)),
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
131
156
|
const effectiveAutoAssignValue = isPersonalWorkspace
|
|
132
157
|
? true
|
|
133
158
|
: (settings?.task_auto_assign_to_self ?? false);
|
|
159
|
+
const normalizedSoundEffectsVolume = String(
|
|
160
|
+
clampTaskSoundEffectsVolume(soundEffectsVolume)
|
|
161
|
+
);
|
|
134
162
|
|
|
135
163
|
return (
|
|
136
164
|
<div className="space-y-8">
|
|
@@ -163,6 +191,54 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
163
191
|
/>
|
|
164
192
|
</SettingItemTab>
|
|
165
193
|
<Separator />
|
|
194
|
+
<SettingItemTab
|
|
195
|
+
title={t('sound_effects')}
|
|
196
|
+
description={t('sound_effects_description')}
|
|
197
|
+
>
|
|
198
|
+
<Switch
|
|
199
|
+
aria-label={t('sound_effects')}
|
|
200
|
+
checked={soundEffectsEnabled}
|
|
201
|
+
onCheckedChange={setSoundEffectsEnabled}
|
|
202
|
+
disabled={soundEffectsEnabledLoading || soundEffectsEnabledPending}
|
|
203
|
+
/>
|
|
204
|
+
</SettingItemTab>
|
|
205
|
+
<Separator />
|
|
206
|
+
<SettingItemTab
|
|
207
|
+
title={t('sound_effects_volume')}
|
|
208
|
+
description={t('sound_effects_volume_description')}
|
|
209
|
+
>
|
|
210
|
+
<Select
|
|
211
|
+
value={normalizedSoundEffectsVolume}
|
|
212
|
+
onValueChange={handleSoundEffectsVolumeChange}
|
|
213
|
+
disabled={
|
|
214
|
+
soundEffectsVolumeLoading ||
|
|
215
|
+
updateSoundEffectsVolume.isPending ||
|
|
216
|
+
!soundEffectsEnabled
|
|
217
|
+
}
|
|
218
|
+
>
|
|
219
|
+
<SelectTrigger
|
|
220
|
+
aria-label={t('sound_effects_volume')}
|
|
221
|
+
className="w-36"
|
|
222
|
+
>
|
|
223
|
+
<SelectValue />
|
|
224
|
+
</SelectTrigger>
|
|
225
|
+
<SelectContent>
|
|
226
|
+
<SelectItem value="15">
|
|
227
|
+
{t('sound_effects_volume_soft')}
|
|
228
|
+
</SelectItem>
|
|
229
|
+
<SelectItem value="35">
|
|
230
|
+
{t('sound_effects_volume_balanced')}
|
|
231
|
+
</SelectItem>
|
|
232
|
+
<SelectItem value="60">
|
|
233
|
+
{t('sound_effects_volume_lively')}
|
|
234
|
+
</SelectItem>
|
|
235
|
+
<SelectItem value="85">
|
|
236
|
+
{t('sound_effects_volume_bold')}
|
|
237
|
+
</SelectItem>
|
|
238
|
+
</SelectContent>
|
|
239
|
+
</Select>
|
|
240
|
+
</SettingItemTab>
|
|
241
|
+
<Separator />
|
|
166
242
|
<SettingItemTab
|
|
167
243
|
title={t('draft_mode')}
|
|
168
244
|
description={t('draft_mode_description')}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import '@testing-library/jest-dom/vitest';
|
|
6
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
7
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { QuickSettingsPopover } from '../../tu-do/shared/task-edit-dialog/components/quick-settings-popover';
|
|
11
|
+
import { TaskSettings } from './task-settings';
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
mockSetSoundEffectsEnabled,
|
|
15
|
+
mockUpdateUserConfigMutate,
|
|
16
|
+
mockConfigState,
|
|
17
|
+
} = vi.hoisted(() => ({
|
|
18
|
+
mockSetSoundEffectsEnabled: vi.fn(),
|
|
19
|
+
mockUpdateUserConfigMutate: vi.fn(),
|
|
20
|
+
mockConfigState: {
|
|
21
|
+
soundEffectsEnabled: true,
|
|
22
|
+
soundEffectsVolume: '35',
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('next-intl', () => ({
|
|
27
|
+
useTranslations: () => (key: string) => key,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('@tuturuuu/ui/hooks/use-user-config', () => ({
|
|
31
|
+
useUserBooleanConfig: (configId: string, defaultValue = false) => {
|
|
32
|
+
if (configId === 'TASK_SOUND_EFFECTS_ENABLED') {
|
|
33
|
+
return {
|
|
34
|
+
isLoading: false,
|
|
35
|
+
isPending: false,
|
|
36
|
+
setValue: mockSetSoundEffectsEnabled,
|
|
37
|
+
toggle: vi.fn(),
|
|
38
|
+
value: mockConfigState.soundEffectsEnabled,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
isLoading: false,
|
|
44
|
+
isPending: false,
|
|
45
|
+
setValue: vi.fn(),
|
|
46
|
+
toggle: vi.fn(),
|
|
47
|
+
value: defaultValue,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
useUserConfig: (configId: string, defaultValue = '') => ({
|
|
51
|
+
data:
|
|
52
|
+
configId === 'TASK_SOUND_EFFECTS_VOLUME'
|
|
53
|
+
? mockConfigState.soundEffectsVolume
|
|
54
|
+
: defaultValue,
|
|
55
|
+
isLoading: false,
|
|
56
|
+
}),
|
|
57
|
+
useUpdateUserConfig: () => ({
|
|
58
|
+
isPending: false,
|
|
59
|
+
mutate: mockUpdateUserConfigMutate,
|
|
60
|
+
}),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
function renderWithQueryClient(children: ReactNode) {
|
|
64
|
+
const queryClient = new QueryClient({
|
|
65
|
+
defaultOptions: {
|
|
66
|
+
mutations: { retry: false },
|
|
67
|
+
queries: { retry: false },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return render(
|
|
72
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('task sound settings controls', () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
mockConfigState.soundEffectsEnabled = true;
|
|
80
|
+
mockConfigState.soundEffectsVolume = '35';
|
|
81
|
+
vi.stubGlobal(
|
|
82
|
+
'fetch',
|
|
83
|
+
vi.fn(() =>
|
|
84
|
+
Promise.resolve({
|
|
85
|
+
json: () =>
|
|
86
|
+
Promise.resolve({
|
|
87
|
+
fade_completed_tasks: false,
|
|
88
|
+
task_auto_assign_to_self: false,
|
|
89
|
+
}),
|
|
90
|
+
ok: true,
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('renders task settings sound controls and persists the switch value', async () => {
|
|
97
|
+
renderWithQueryClient(<TaskSettings />);
|
|
98
|
+
|
|
99
|
+
expect(await screen.findByText('sound_effects')).toBeInTheDocument();
|
|
100
|
+
expect(screen.getByText('sound_effects_volume')).toBeInTheDocument();
|
|
101
|
+
|
|
102
|
+
fireEvent.click(screen.getByRole('switch', { name: 'sound_effects' }));
|
|
103
|
+
|
|
104
|
+
expect(mockSetSoundEffectsEnabled).toHaveBeenCalledWith(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('renders the quick settings sound switch and persists changes', async () => {
|
|
108
|
+
renderWithQueryClient(<QuickSettingsPopover />);
|
|
109
|
+
|
|
110
|
+
const trigger = screen.getByRole('button', { name: 'Quick Settings' });
|
|
111
|
+
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(trigger).not.toBeDisabled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
fireEvent.click(trigger);
|
|
117
|
+
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(screen.getByText('sound_effects')).toBeInTheDocument();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
fireEvent.click(screen.getByRole('switch', { name: 'sound_effects' }));
|
|
123
|
+
|
|
124
|
+
expect(mockSetSoundEffectsEnabled).toHaveBeenCalledWith(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
import { PeriodBreakdownPanel } from './period-charts';
|
|
74
74
|
import { TransactionCard } from './transaction-card';
|
|
75
75
|
import { TransactionStatistics } from './transaction-statistics';
|
|
76
|
+
import { mergeLinkedTransferTransactions } from './transfer-merge';
|
|
76
77
|
|
|
77
78
|
interface InfiniteTransactionsListProps {
|
|
78
79
|
wsId: string;
|
|
@@ -446,6 +447,10 @@ export function InfiniteTransactionsList({
|
|
|
446
447
|
const allTransactions = usePeriods
|
|
447
448
|
? []
|
|
448
449
|
: dailyData?.pages.flatMap((page) => page.data) || [];
|
|
450
|
+
const visibleTransactions = useMemo(
|
|
451
|
+
() => mergeLinkedTransferTransactions(allTransactions),
|
|
452
|
+
[allTransactions]
|
|
453
|
+
);
|
|
449
454
|
|
|
450
455
|
// All periods for period-based views
|
|
451
456
|
const allPeriods: TransactionPeriod[] = usePeriods
|
|
@@ -485,26 +490,32 @@ export function InfiniteTransactionsList({
|
|
|
485
490
|
const groupedTransactions = useMemo(() => {
|
|
486
491
|
// For period-based views, convert periods to grouped transactions format
|
|
487
492
|
if (usePeriods) {
|
|
488
|
-
return allPeriods.map((period) =>
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
493
|
+
return allPeriods.map((period) => {
|
|
494
|
+
const transactions = mergeLinkedTransferTransactions(
|
|
495
|
+
period.transactions || []
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
date: period.periodStart,
|
|
500
|
+
label: generatePeriodLabel(period.periodStart, viewMode),
|
|
501
|
+
transactions,
|
|
502
|
+
// Store period stats for display
|
|
503
|
+
periodStats: {
|
|
504
|
+
totalIncome: period.totalIncome,
|
|
505
|
+
totalExpense: period.totalExpense,
|
|
506
|
+
netTotal: period.netTotal,
|
|
507
|
+
transactionCount: transactions.length,
|
|
508
|
+
hasRedactedAmounts: period.hasRedactedAmounts,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
});
|
|
501
512
|
}
|
|
502
513
|
|
|
503
514
|
// For daily view, group transactions by date in the user's timezone
|
|
504
515
|
const groups: GroupedTransactions[] = [];
|
|
505
516
|
const now = dayjs().tz(resolvedTimezone);
|
|
506
517
|
|
|
507
|
-
|
|
518
|
+
visibleTransactions.forEach((transaction) => {
|
|
508
519
|
// Parse transaction date in the user's timezone
|
|
509
520
|
const transactionDate = dayjs(transaction.taken_at).tz(resolvedTimezone);
|
|
510
521
|
const dateKey = transactionDate.format('YYYY-MM-DD');
|
|
@@ -543,7 +554,7 @@ export function InfiniteTransactionsList({
|
|
|
543
554
|
|
|
544
555
|
return groups;
|
|
545
556
|
}, [
|
|
546
|
-
|
|
557
|
+
visibleTransactions,
|
|
547
558
|
allPeriods,
|
|
548
559
|
usePeriods,
|
|
549
560
|
viewMode,
|
|
@@ -604,7 +615,7 @@ export function InfiniteTransactionsList({
|
|
|
604
615
|
// Check if there's no data (either no transactions for daily or no periods for other views)
|
|
605
616
|
const hasNoData = usePeriods
|
|
606
617
|
? allPeriods.length === 0
|
|
607
|
-
:
|
|
618
|
+
: visibleTransactions.length === 0;
|
|
608
619
|
|
|
609
620
|
if (hasNoData) {
|
|
610
621
|
return (
|
|
@@ -627,7 +638,7 @@ export function InfiniteTransactionsList({
|
|
|
627
638
|
{/* Statistics Summary - Only show when filters are active (daily view only uses transactions for stats) */}
|
|
628
639
|
{hasActiveFilter && (stats || isStatsLoading) && !usePeriods && (
|
|
629
640
|
<TransactionStatistics
|
|
630
|
-
transactions={
|
|
641
|
+
transactions={visibleTransactions}
|
|
631
642
|
stats={stats}
|
|
632
643
|
isLoading={isStatsLoading}
|
|
633
644
|
currency={currency}
|
|
@@ -1023,7 +1034,7 @@ export function InfiniteTransactionsList({
|
|
|
1023
1034
|
{!hasNextPage &&
|
|
1024
1035
|
(usePeriods
|
|
1025
1036
|
? allPeriods.length > 5
|
|
1026
|
-
:
|
|
1037
|
+
: visibleTransactions.length > 10) && (
|
|
1027
1038
|
<div className="rounded-xl border border-dashed bg-muted/20 p-6 text-center">
|
|
1028
1039
|
<p className="text-muted-foreground text-sm">
|
|
1029
1040
|
{t('user-data-table.common.end_of_list')}
|
|
@@ -83,35 +83,8 @@ export function TransactionCard({
|
|
|
83
83
|
const [isHovered, setIsHovered] = useState(false);
|
|
84
84
|
const { isConfidential: areNumbersHidden } =
|
|
85
85
|
useFinanceConfidentialVisibility();
|
|
86
|
-
const effectiveCurrency = transaction.wallet_currency || currency;
|
|
87
|
-
const isExpense = (transaction.amount || 0) < 0;
|
|
88
86
|
const isTransfer = !!transaction.transfer;
|
|
89
87
|
|
|
90
|
-
// Currency conversion for foreign-currency transactions
|
|
91
|
-
const isForeignCurrency =
|
|
92
|
-
effectiveCurrency.toUpperCase() !== currency.toUpperCase();
|
|
93
|
-
const { data: exchangeRateData } = useExchangeRates();
|
|
94
|
-
const convertedAmount = useMemo(() => {
|
|
95
|
-
if (
|
|
96
|
-
!isForeignCurrency ||
|
|
97
|
-
transaction.amount == null ||
|
|
98
|
-
!exchangeRateData?.data
|
|
99
|
-
)
|
|
100
|
-
return null;
|
|
101
|
-
return convertCurrency(
|
|
102
|
-
transaction.amount,
|
|
103
|
-
effectiveCurrency,
|
|
104
|
-
currency,
|
|
105
|
-
exchangeRateData.data
|
|
106
|
-
);
|
|
107
|
-
}, [
|
|
108
|
-
isForeignCurrency,
|
|
109
|
-
transaction.amount,
|
|
110
|
-
effectiveCurrency,
|
|
111
|
-
currency,
|
|
112
|
-
exchangeRateData?.data,
|
|
113
|
-
]);
|
|
114
|
-
|
|
115
88
|
// Check if transaction is confidential
|
|
116
89
|
const isConfidential =
|
|
117
90
|
transaction.is_amount_confidential ||
|
|
@@ -154,6 +127,65 @@ export function TransactionCard({
|
|
|
154
127
|
return wallets.find((w) => w.id === transaction.transfer?.linked_wallet_id);
|
|
155
128
|
}, [transaction.transfer?.linked_wallet_id, wallets]);
|
|
156
129
|
|
|
130
|
+
const transferDisplay = transaction.transfer
|
|
131
|
+
? transaction.transfer.is_origin
|
|
132
|
+
? {
|
|
133
|
+
amount: transaction.amount,
|
|
134
|
+
amountCurrency: transaction.wallet_currency,
|
|
135
|
+
destinationIcon: linkedWallet?.icon,
|
|
136
|
+
destinationImageSrc: linkedWallet?.image_src,
|
|
137
|
+
destinationWalletId: transaction.transfer.linked_wallet_id,
|
|
138
|
+
destinationWalletName: transaction.transfer.linked_wallet_name,
|
|
139
|
+
originIcon: wallet?.icon,
|
|
140
|
+
originImageSrc: wallet?.image_src,
|
|
141
|
+
originWalletId: transaction.wallet_id,
|
|
142
|
+
originWalletName: transaction.wallet,
|
|
143
|
+
secondaryAmount: transaction.transfer.linked_amount,
|
|
144
|
+
secondaryCurrency: transaction.transfer.linked_wallet_currency,
|
|
145
|
+
}
|
|
146
|
+
: {
|
|
147
|
+
amount: transaction.transfer.linked_amount ?? transaction.amount,
|
|
148
|
+
amountCurrency:
|
|
149
|
+
transaction.transfer.linked_wallet_currency ||
|
|
150
|
+
transaction.wallet_currency,
|
|
151
|
+
destinationIcon: wallet?.icon,
|
|
152
|
+
destinationImageSrc: wallet?.image_src,
|
|
153
|
+
destinationWalletId: transaction.wallet_id,
|
|
154
|
+
destinationWalletName: transaction.wallet,
|
|
155
|
+
originIcon: linkedWallet?.icon,
|
|
156
|
+
originImageSrc: linkedWallet?.image_src,
|
|
157
|
+
originWalletId: transaction.transfer.linked_wallet_id,
|
|
158
|
+
originWalletName: transaction.transfer.linked_wallet_name,
|
|
159
|
+
secondaryAmount: transaction.amount,
|
|
160
|
+
secondaryCurrency: transaction.wallet_currency,
|
|
161
|
+
}
|
|
162
|
+
: null;
|
|
163
|
+
const displayAmount = transferDisplay?.amount ?? transaction.amount;
|
|
164
|
+
const effectiveCurrency =
|
|
165
|
+
transferDisplay?.amountCurrency || transaction.wallet_currency || currency;
|
|
166
|
+
const isExpense = (displayAmount || 0) < 0;
|
|
167
|
+
|
|
168
|
+
// Currency conversion for foreign-currency transactions
|
|
169
|
+
const isForeignCurrency =
|
|
170
|
+
effectiveCurrency.toUpperCase() !== currency.toUpperCase();
|
|
171
|
+
const { data: exchangeRateData } = useExchangeRates();
|
|
172
|
+
const convertedAmount = useMemo(() => {
|
|
173
|
+
if (!isForeignCurrency || displayAmount == null || !exchangeRateData?.data)
|
|
174
|
+
return null;
|
|
175
|
+
return convertCurrency(
|
|
176
|
+
displayAmount,
|
|
177
|
+
effectiveCurrency,
|
|
178
|
+
currency,
|
|
179
|
+
exchangeRateData.data
|
|
180
|
+
);
|
|
181
|
+
}, [
|
|
182
|
+
isForeignCurrency,
|
|
183
|
+
displayAmount,
|
|
184
|
+
effectiveCurrency,
|
|
185
|
+
currency,
|
|
186
|
+
exchangeRateData?.data,
|
|
187
|
+
]);
|
|
188
|
+
|
|
157
189
|
// Determine if we should use custom styling
|
|
158
190
|
const hasCustomStyling = Boolean(customColorStyles);
|
|
159
191
|
|
|
@@ -331,37 +363,37 @@ export function TransactionCard({
|
|
|
331
363
|
{transaction.category}
|
|
332
364
|
</Badge>
|
|
333
365
|
) : null}
|
|
334
|
-
{transaction.wallet && (
|
|
366
|
+
{(transaction.wallet || transferDisplay) && (
|
|
335
367
|
<div className="flex items-center gap-1">
|
|
336
|
-
{isTransfer &&
|
|
368
|
+
{isTransfer && transferDisplay ? (
|
|
337
369
|
<div className="flex items-center rounded-full border border-dynamic-blue/20 bg-dynamic-blue/5 py-0.5 pr-1 pl-1">
|
|
338
370
|
<Link
|
|
339
|
-
href={`/${wsId}${financeHref(`/wallets/${
|
|
371
|
+
href={`/${wsId}${financeHref(`/wallets/${transferDisplay.originWalletId}`)}`}
|
|
340
372
|
onClick={(e) => e.stopPropagation()}
|
|
341
373
|
>
|
|
342
374
|
<span className="flex items-center gap-1 rounded-full px-1.5 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-dynamic-blue/10 hover:text-foreground sm:text-xs">
|
|
343
375
|
<WalletIconDisplay
|
|
344
|
-
icon={
|
|
345
|
-
imageSrc={
|
|
376
|
+
icon={transferDisplay.originIcon}
|
|
377
|
+
imageSrc={transferDisplay.originImageSrc}
|
|
346
378
|
size="sm"
|
|
347
379
|
className="h-3 w-3"
|
|
348
380
|
/>
|
|
349
|
-
{
|
|
381
|
+
{transferDisplay.originWalletName}
|
|
350
382
|
</span>
|
|
351
383
|
</Link>
|
|
352
384
|
<ArrowRight className="mx-0.5 h-3 w-3 shrink-0 text-dynamic-blue" />
|
|
353
385
|
<Link
|
|
354
|
-
href={`/${wsId}${financeHref(`/wallets/${
|
|
386
|
+
href={`/${wsId}${financeHref(`/wallets/${transferDisplay.destinationWalletId}`)}`}
|
|
355
387
|
onClick={(e) => e.stopPropagation()}
|
|
356
388
|
>
|
|
357
389
|
<span className="flex items-center gap-1 rounded-full px-1.5 py-0.5 font-medium text-[11px] text-dynamic-blue transition-colors hover:bg-dynamic-blue/10 sm:text-xs">
|
|
358
390
|
<WalletIconDisplay
|
|
359
|
-
icon={
|
|
360
|
-
imageSrc={
|
|
391
|
+
icon={transferDisplay.destinationIcon}
|
|
392
|
+
imageSrc={transferDisplay.destinationImageSrc}
|
|
361
393
|
size="sm"
|
|
362
394
|
className="h-3 w-3"
|
|
363
395
|
/>
|
|
364
|
-
{
|
|
396
|
+
{transferDisplay.destinationWalletName}
|
|
365
397
|
</span>
|
|
366
398
|
</Link>
|
|
367
399
|
</div>
|
|
@@ -401,7 +433,7 @@ export function TransactionCard({
|
|
|
401
433
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
|
402
434
|
<div className="flex flex-col items-end">
|
|
403
435
|
<ConfidentialAmount
|
|
404
|
-
amount={
|
|
436
|
+
amount={displayAmount ?? null}
|
|
405
437
|
isConfidential={transaction.is_amount_confidential || false}
|
|
406
438
|
currency={effectiveCurrency}
|
|
407
439
|
className={cn(
|
|
@@ -426,16 +458,16 @@ export function TransactionCard({
|
|
|
426
458
|
}
|
|
427
459
|
/>
|
|
428
460
|
{isTransfer &&
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
461
|
+
transferDisplay?.secondaryAmount != null &&
|
|
462
|
+
transferDisplay.secondaryCurrency &&
|
|
463
|
+
transferDisplay.secondaryCurrency.toUpperCase() !==
|
|
432
464
|
effectiveCurrency.toUpperCase() && (
|
|
433
465
|
<span className="text-[10px] text-dynamic-blue tabular-nums sm:text-xs">
|
|
434
466
|
{areNumbersHidden
|
|
435
467
|
? FINANCE_HIDDEN_AMOUNT
|
|
436
468
|
: formatCurrency(
|
|
437
|
-
Math.abs(
|
|
438
|
-
|
|
469
|
+
Math.abs(transferDisplay.secondaryAmount),
|
|
470
|
+
transferDisplay.secondaryCurrency,
|
|
439
471
|
undefined,
|
|
440
472
|
{ signDisplay: 'always' }
|
|
441
473
|
)}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Transaction } from '@tuturuuu/types/primitives/Transaction';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { mergeLinkedTransferTransactions } from './transfer-merge';
|
|
4
|
+
|
|
5
|
+
function transaction(
|
|
6
|
+
id: string,
|
|
7
|
+
amount: number,
|
|
8
|
+
transfer?: Transaction['transfer']
|
|
9
|
+
): Transaction {
|
|
10
|
+
return {
|
|
11
|
+
amount,
|
|
12
|
+
id,
|
|
13
|
+
taken_at: '2026-06-11T00:00:00.000Z',
|
|
14
|
+
wallet_id: `wallet-${id}`,
|
|
15
|
+
wallet: `Wallet ${id}`,
|
|
16
|
+
transfer,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const originTransfer = {
|
|
21
|
+
is_origin: true,
|
|
22
|
+
linked_amount: 100,
|
|
23
|
+
linked_transaction_id: 'destination',
|
|
24
|
+
linked_wallet_id: 'wallet-destination',
|
|
25
|
+
linked_wallet_name: 'Destination Wallet',
|
|
26
|
+
} satisfies Transaction['transfer'];
|
|
27
|
+
|
|
28
|
+
const destinationTransfer = {
|
|
29
|
+
is_origin: false,
|
|
30
|
+
linked_amount: -100,
|
|
31
|
+
linked_transaction_id: 'origin',
|
|
32
|
+
linked_wallet_id: 'wallet-origin',
|
|
33
|
+
linked_wallet_name: 'Origin Wallet',
|
|
34
|
+
} satisfies Transaction['transfer'];
|
|
35
|
+
|
|
36
|
+
describe('mergeLinkedTransferTransactions', () => {
|
|
37
|
+
it('collapses a linked origin and destination pair into one transfer row', () => {
|
|
38
|
+
const origin = transaction('origin', -100, originTransfer);
|
|
39
|
+
const destination = transaction('destination', 100, destinationTransfer);
|
|
40
|
+
|
|
41
|
+
expect(mergeLinkedTransferTransactions([origin, destination])).toEqual([
|
|
42
|
+
origin,
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('keeps the origin transfer when the destination leg appears first', () => {
|
|
47
|
+
const origin = transaction('origin', -100, originTransfer);
|
|
48
|
+
const destination = transaction('destination', 100, destinationTransfer);
|
|
49
|
+
|
|
50
|
+
expect(mergeLinkedTransferTransactions([destination, origin])).toEqual([
|
|
51
|
+
origin,
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps an unmatched transfer leg visible', () => {
|
|
56
|
+
const destination = transaction('destination', 100, destinationTransfer);
|
|
57
|
+
|
|
58
|
+
expect(mergeLinkedTransferTransactions([destination])).toEqual([
|
|
59
|
+
destination,
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not merge unrelated rows with matching amounts', () => {
|
|
64
|
+
const expense = transaction('expense', -100);
|
|
65
|
+
const income = transaction('income', 100);
|
|
66
|
+
|
|
67
|
+
expect(mergeLinkedTransferTransactions([expense, income])).toEqual([
|
|
68
|
+
expense,
|
|
69
|
+
income,
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('keeps unrelated row order while using the first pair slot', () => {
|
|
74
|
+
const first = transaction('first', 1);
|
|
75
|
+
const second = transaction('second', 2);
|
|
76
|
+
const third = transaction('third', 3);
|
|
77
|
+
const origin = transaction('origin', -100, originTransfer);
|
|
78
|
+
const destination = transaction('destination', 100, destinationTransfer);
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
mergeLinkedTransferTransactions([
|
|
82
|
+
first,
|
|
83
|
+
destination,
|
|
84
|
+
second,
|
|
85
|
+
origin,
|
|
86
|
+
third,
|
|
87
|
+
])
|
|
88
|
+
).toEqual([first, origin, second, third]);
|
|
89
|
+
});
|
|
90
|
+
});
|