@tuturuuu/ui 0.6.2 → 0.8.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/biome.json +1 -1
  3. package/package.json +11 -11
  4. package/src/components/ui/calendar-app/components/calendar-connections.tsx +17 -13
  5. package/src/components/ui/calendar-app/components/connected-accounts-dialog.tsx +2 -5
  6. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +2 -5
  7. package/src/components/ui/calendar.test.tsx +24 -0
  8. package/src/components/ui/calendar.tsx +1 -0
  9. package/src/components/ui/currency-input.test.tsx +43 -0
  10. package/src/components/ui/currency-input.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  12. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  13. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  14. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  15. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  16. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  17. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  18. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  19. package/src/components/ui/date-time-picker.tsx +352 -234
  20. package/src/components/ui/finance/categories-tags-tabs.tsx +23 -1
  21. package/src/components/ui/finance/command/finance-command-actions.test.tsx +48 -0
  22. package/src/components/ui/finance/command/finance-command-actions.tsx +200 -0
  23. package/src/components/ui/finance/command/finance-command-provider.test.tsx +151 -0
  24. package/src/components/ui/finance/command/finance-command-provider.tsx +250 -0
  25. package/src/components/ui/finance/command/finance-command-results.tsx +262 -0
  26. package/src/components/ui/finance/invoices/pending-invoices-table.tsx +22 -9
  27. package/src/components/ui/finance/shared/quick-actions.tsx +39 -90
  28. package/src/components/ui/finance/tags/tag-manager.tsx +24 -5
  29. package/src/components/ui/finance/transactions/form-basic-tab.tsx +33 -49
  30. package/src/components/ui/finance/transactions/form-types.ts +5 -0
  31. package/src/components/ui/finance/transactions/form.test.tsx +105 -22
  32. package/src/components/ui/finance/transactions/form.tsx +116 -20
  33. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +13 -6
  34. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  35. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +25 -1
  36. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +16 -3
  37. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +3 -0
  38. package/src/components/ui/finance/transactions/transactionId/transaction-details-page.tsx +3 -0
  39. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +6 -0
  40. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +20 -2
  41. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -0
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +7 -2
  43. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +7 -2
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +38 -1
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +5 -0
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +18 -2
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +3 -0
  48. package/src/components/ui/finance/wallets/wallets-page.tsx +3 -0
  49. package/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +2 -9
  50. package/src/components/ui/money-input.test.tsx +64 -0
  51. package/src/components/ui/money-input.tsx +63 -0
  52. package/src/components/ui/optional-time-picker.tsx +95 -0
  53. package/src/components/ui/quick-command-center.test.tsx +90 -0
  54. package/src/components/ui/quick-command-center.tsx +190 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +126 -50
  56. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +23 -20
  58. package/src/components/ui/storefront/image-panel.tsx +6 -0
  59. package/src/components/ui/storefront/index.ts +11 -0
  60. package/src/components/ui/storefront/listing-card.tsx +84 -22
  61. package/src/components/ui/storefront/product-detail.tsx +289 -0
  62. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  63. package/src/components/ui/storefront/storefront-surface.test.tsx +132 -5
  64. package/src/components/ui/storefront/storefront-surface.tsx +371 -128
  65. package/src/components/ui/storefront/types.ts +25 -1
  66. package/src/components/ui/storefront/utils.ts +118 -13
  67. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  68. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  69. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  70. package/src/components/ui/text-editor/content-migration.ts +41 -18
  71. package/src/components/ui/text-editor/extensions.ts +1 -1
  72. package/src/components/ui/text-editor/image-extension.ts +40 -18
  73. package/src/components/ui/text-editor/video-extension.ts +11 -2
  74. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  75. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  76. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  77. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  78. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  79. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  80. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  81. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  82. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  83. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  84. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  85. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  86. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  87. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  88. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  89. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  90. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  91. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  92. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  93. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  94. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  95. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.test.ts +171 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +200 -36
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +21 -2
  102. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  103. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  104. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  105. package/src/hooks/useBoardRealtime.ts +6 -3
  106. package/src/hooks/useBoardRealtime.types.ts +11 -0
  107. package/src/hooks/useCursorTracking.ts +91 -27
  108. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -1,10 +1,30 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
- import { render, screen } from '@testing-library/react';
3
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import type { ComponentProps } from 'react';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { CreateDialogFeatureSummary } from '../shared/create-dialog-feature-summary';
5
6
  import { TransactionForm } from './form';
6
7
  import { TransactionsCreateSummary } from './transactions-create-summary';
7
8
 
9
+ const mocks = vi.hoisted(() => ({
10
+ createTransaction: vi.fn(),
11
+ createTransfer: vi.fn(),
12
+ deleteWorkspaceStorageObjects: vi.fn(),
13
+ listTransactionCategories: vi.fn(),
14
+ listTransactionTagLinks: vi.fn(),
15
+ listTransactionTags: vi.fn(),
16
+ listWallets: vi.fn(),
17
+ listWorkspaceStorageObjects: vi.fn(),
18
+ preferences: {
19
+ lastSelections: {} as { categoryId?: string; walletId?: string },
20
+ rememberLastSelections: true,
21
+ },
22
+ saveLastSelections: vi.fn(),
23
+ updateTransaction: vi.fn(),
24
+ updateTransfer: vi.fn(),
25
+ uploadWorkspaceStorageFile: vi.fn(),
26
+ }));
27
+
8
28
  vi.mock('next/navigation', () => ({
9
29
  useRouter: () => ({
10
30
  refresh: vi.fn(),
@@ -27,9 +47,9 @@ vi.mock('@tuturuuu/ui/hooks/use-finance-transaction-preferences', () => ({
27
47
  useFinanceTransactionPreferences: () => ({
28
48
  isLastSelectionsInitialized: true,
29
49
  isLoadingRememberLastSelections: false,
30
- lastSelections: {},
31
- rememberLastSelections: true,
32
- saveLastSelections: vi.fn(),
50
+ lastSelections: mocks.preferences.lastSelections,
51
+ rememberLastSelections: mocks.preferences.rememberLastSelections,
52
+ saveLastSelections: mocks.saveLastSelections,
33
53
  }),
34
54
  }));
35
55
 
@@ -38,21 +58,34 @@ vi.mock('@tuturuuu/ui/hooks/use-workspace-config', () => ({
38
58
  }));
39
59
 
40
60
  vi.mock('@tuturuuu/internal-api', () => ({
41
- createTransaction: vi.fn(),
42
- createTransfer: vi.fn(),
43
- deleteWorkspaceStorageObjects: vi.fn(),
44
- listTransactionCategories: vi.fn().mockResolvedValue([]),
45
- listTransactionTagLinks: vi.fn().mockResolvedValue([]),
46
- listTransactionTags: vi.fn().mockResolvedValue([]),
47
- listWallets: vi.fn().mockResolvedValue([]),
48
- listWorkspaceStorageObjects: vi.fn().mockResolvedValue({
49
- data: [],
50
- hasMore: false,
51
- total: 0,
52
- }),
53
- updateTransaction: vi.fn(),
54
- updateTransfer: vi.fn(),
55
- uploadWorkspaceStorageFile: vi.fn(),
61
+ createTransaction: (...args: Parameters<typeof mocks.createTransaction>) =>
62
+ mocks.createTransaction(...args),
63
+ createTransfer: (...args: Parameters<typeof mocks.createTransfer>) =>
64
+ mocks.createTransfer(...args),
65
+ deleteWorkspaceStorageObjects: (
66
+ ...args: Parameters<typeof mocks.deleteWorkspaceStorageObjects>
67
+ ) => mocks.deleteWorkspaceStorageObjects(...args),
68
+ listTransactionCategories: (
69
+ ...args: Parameters<typeof mocks.listTransactionCategories>
70
+ ) => mocks.listTransactionCategories(...args),
71
+ listTransactionTagLinks: (
72
+ ...args: Parameters<typeof mocks.listTransactionTagLinks>
73
+ ) => mocks.listTransactionTagLinks(...args),
74
+ listTransactionTags: (
75
+ ...args: Parameters<typeof mocks.listTransactionTags>
76
+ ) => mocks.listTransactionTags(...args),
77
+ listWallets: (...args: Parameters<typeof mocks.listWallets>) =>
78
+ mocks.listWallets(...args),
79
+ listWorkspaceStorageObjects: (
80
+ ...args: Parameters<typeof mocks.listWorkspaceStorageObjects>
81
+ ) => mocks.listWorkspaceStorageObjects(...args),
82
+ updateTransaction: (...args: Parameters<typeof mocks.updateTransaction>) =>
83
+ mocks.updateTransaction(...args),
84
+ updateTransfer: (...args: Parameters<typeof mocks.updateTransfer>) =>
85
+ mocks.updateTransfer(...args),
86
+ uploadWorkspaceStorageFile: (
87
+ ...args: Parameters<typeof mocks.uploadWorkspaceStorageFile>
88
+ ) => mocks.uploadWorkspaceStorageFile(...args),
56
89
  }));
57
90
 
58
91
  vi.mock('@tuturuuu/internal-api/finance', () => ({
@@ -70,7 +103,9 @@ vi.mock('@tuturuuu/ui/sonner', () => ({
70
103
  },
71
104
  }));
72
105
 
73
- function renderTransactionForm() {
106
+ function renderTransactionForm(
107
+ props: Partial<ComponentProps<typeof TransactionForm>> = {}
108
+ ) {
74
109
  const queryClient = new QueryClient({
75
110
  defaultOptions: {
76
111
  queries: {
@@ -81,18 +116,34 @@ function renderTransactionForm() {
81
116
 
82
117
  return render(
83
118
  <QueryClientProvider client={queryClient}>
84
- <TransactionForm wsId="ws-1" canCreateTransactions />
119
+ <TransactionForm wsId="ws-1" canCreateTransactions {...props} />
85
120
  </QueryClientProvider>
86
121
  );
87
122
  }
88
123
 
89
124
  describe('TransactionForm', () => {
125
+ afterEach(() => {
126
+ vi.useRealTimers();
127
+ });
128
+
90
129
  beforeEach(() => {
130
+ vi.clearAllMocks();
91
131
  globalThis.ResizeObserver = class ResizeObserver {
92
132
  disconnect() {}
93
133
  observe() {}
94
134
  unobserve() {}
95
135
  };
136
+ mocks.preferences.lastSelections = {};
137
+ mocks.preferences.rememberLastSelections = true;
138
+ mocks.listTransactionCategories.mockResolvedValue([]);
139
+ mocks.listTransactionTagLinks.mockResolvedValue([]);
140
+ mocks.listTransactionTags.mockResolvedValue([]);
141
+ mocks.listWallets.mockResolvedValue([]);
142
+ mocks.listWorkspaceStorageObjects.mockResolvedValue({
143
+ data: [],
144
+ hasMore: false,
145
+ total: 0,
146
+ });
96
147
  });
97
148
 
98
149
  it('renders the create transaction form without invalid component errors', () => {
@@ -102,6 +153,38 @@ describe('TransactionForm', () => {
102
153
  expect(screen.getByText('ws-transactions.create')).toBeVisible();
103
154
  });
104
155
 
156
+ it('renders the optional time control on by default for new transactions', () => {
157
+ vi.useFakeTimers();
158
+ vi.setSystemTime(new Date('2026-06-13T12:34:00.000Z'));
159
+
160
+ renderTransactionForm({ timezone: 'UTC' });
161
+
162
+ const includeTimeSwitch = screen.getByRole('switch', {
163
+ name: 'transaction-data-table.include_time',
164
+ });
165
+
166
+ expect(includeTimeSwitch).toHaveAttribute('data-state', 'checked');
167
+ expect(screen.getByText('12:34 PM')).toBeVisible();
168
+ });
169
+
170
+ it('keeps the contextual wallet selected when wallet context is preferred', async () => {
171
+ mocks.preferences.lastSelections = { walletId: 'wallet-remembered' };
172
+ mocks.listWallets.mockResolvedValue([
173
+ { id: 'wallet-remembered', name: 'Remembered Wallet' },
174
+ { id: 'wallet-current', name: 'Current Wallet' },
175
+ ]);
176
+
177
+ renderTransactionForm({
178
+ initialTransaction: { origin_wallet_id: 'wallet-current' },
179
+ preferInitialWalletSelection: true,
180
+ });
181
+
182
+ await waitFor(() => {
183
+ expect(screen.getByText('Current Wallet')).toBeVisible();
184
+ });
185
+ expect(screen.queryByText('Remembered Wallet')).not.toBeInTheDocument();
186
+ });
187
+
105
188
  it('renders inside the create dialog summary when opened from query state', () => {
106
189
  const queryClient = new QueryClient({
107
190
  defaultOptions: {
@@ -39,6 +39,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
39
39
  import { convertCurrency } from '@tuturuuu/utils/exchange-rates';
40
40
  import { shouldLockFinanceWalletSelectionOnCreate } from '@tuturuuu/utils/finance';
41
41
  import { joinPath } from '@tuturuuu/utils/path-helper';
42
+ import {
43
+ buildDateInTimezone,
44
+ getDatePartsInTimezone,
45
+ } from '@tuturuuu/utils/task-date-timezone';
42
46
  import { useRouter } from 'next/navigation';
43
47
  import { useLocale, useTranslations } from 'next-intl';
44
48
  import { useEffect, useMemo, useState } from 'react';
@@ -62,6 +66,67 @@ import {
62
66
  } from './transaction-attachments-field';
63
67
 
64
68
  const TRANSACTION_ATTACHMENT_PAGE_SIZE = 100;
69
+ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
70
+
71
+ function startOfDayInTimezone(date: Date, timezone?: string | null) {
72
+ const resolvedTimezone = timezone || 'auto';
73
+ const parts = getDatePartsInTimezone(date, resolvedTimezone);
74
+
75
+ return buildDateInTimezone(
76
+ parts.year,
77
+ parts.month,
78
+ parts.day,
79
+ 0,
80
+ 0,
81
+ resolvedTimezone
82
+ );
83
+ }
84
+
85
+ function parseDateOnlyInTimezone(value: string, timezone?: string | null) {
86
+ const [year, month, day] = value.split('-').map(Number);
87
+
88
+ if (!year || !month || !day)
89
+ return startOfDayInTimezone(new Date(), timezone);
90
+
91
+ return buildDateInTimezone(year, month, day, 0, 0, timezone || 'auto');
92
+ }
93
+
94
+ function resolveInitialTakenAt({
95
+ data,
96
+ initialIsTransfer,
97
+ initialTransaction,
98
+ initialTransfer,
99
+ timezone,
100
+ }: Pick<
101
+ TransactionFormProps,
102
+ 'data' | 'initialTransaction' | 'initialTransfer' | 'timezone'
103
+ > & {
104
+ initialIsTransfer: boolean;
105
+ }) {
106
+ const existingTakenAt = data?.taken_at;
107
+
108
+ if (existingTakenAt) {
109
+ const isDateOnly =
110
+ typeof existingTakenAt === 'string' &&
111
+ DATE_ONLY_PATTERN.test(existingTakenAt);
112
+
113
+ return {
114
+ date: isDateOnly
115
+ ? parseDateOnlyInTimezone(existingTakenAt, timezone)
116
+ : new Date(existingTakenAt),
117
+ includeTime: !!data?.id,
118
+ };
119
+ }
120
+
121
+ const initialTakenAt = initialIsTransfer
122
+ ? initialTransfer?.taken_at
123
+ : initialTransaction?.taken_at;
124
+
125
+ return {
126
+ date: initialTakenAt ?? new Date(),
127
+ includeTime: true,
128
+ };
129
+ }
65
130
 
66
131
  export function TransactionForm({
67
132
  wsId,
@@ -76,6 +141,9 @@ export function TransactionForm({
76
141
  initialMode = 'transaction',
77
142
  initialTransaction,
78
143
  initialTransfer,
144
+ timezone,
145
+ preferInitialWalletSelection = false,
146
+ refreshPageOnFinish = false,
79
147
  permissionRequestUser,
80
148
  }: TransactionFormProps) {
81
149
  const t = useTranslations();
@@ -87,6 +155,20 @@ export function TransactionForm({
87
155
  []
88
156
  );
89
157
  const initialIsTransfer = initialMode === 'transfer' || !!data?.transfer;
158
+ const initialTakenAt = useMemo(
159
+ () =>
160
+ resolveInitialTakenAt({
161
+ data,
162
+ initialIsTransfer,
163
+ initialTransaction,
164
+ initialTransfer,
165
+ timezone,
166
+ }),
167
+ [data, initialIsTransfer, initialTransaction, initialTransfer, timezone]
168
+ );
169
+ const [includeTakenAtTime, setIncludeTakenAtTime] = useState(
170
+ initialTakenAt.includeTime
171
+ );
90
172
  const [isTransfer, setIsTransfer] = useState(initialIsTransfer);
91
173
  // Start in override mode when editing an existing transfer (preserve stored amounts).
92
174
  // Start in auto mode for new transfers so the exchange rate pre-fills destination.
@@ -173,11 +255,7 @@ export function TransactionForm({
173
255
  ? Math.abs(data.transfer.linked_amount)
174
256
  : initialTransfer?.destination_amount,
175
257
  category_id: data?.category_id || initialTransaction?.category_id || '',
176
- taken_at: data?.taken_at
177
- ? new Date(data.taken_at)
178
- : initialIsTransfer
179
- ? (initialTransfer?.taken_at ?? new Date())
180
- : (initialTransaction?.taken_at ?? new Date()),
258
+ taken_at: initialTakenAt.date,
181
259
  report_opt_in: data?.report_opt_in ?? true,
182
260
  tag_ids: [] as string[],
183
261
  is_transfer: initialIsTransfer,
@@ -190,6 +268,10 @@ export function TransactionForm({
190
268
  },
191
269
  });
192
270
 
271
+ useEffect(() => {
272
+ setIncludeTakenAtTime(initialTakenAt.includeTime);
273
+ }, [initialTakenAt.includeTime]);
274
+
193
275
  // Keep is_transfer in sync with local state
194
276
  useEffect(() => {
195
277
  if (form.getValues('is_transfer') !== isTransfer) {
@@ -232,25 +314,35 @@ export function TransactionForm({
232
314
  wallets.some((wallet) => wallet.id === lastSelections.walletId)
233
315
  ? lastSelections.walletId
234
316
  : '';
235
- const nextWalletSelection =
236
- rememberedWalletId ||
237
- (contextualWalletId &&
317
+ const contextualWalletSelection =
318
+ contextualWalletId &&
238
319
  wallets.some((wallet) => wallet.id === contextualWalletId)
239
320
  ? contextualWalletId
240
- : '') ||
241
- (defaultWalletId &&
242
- wallets.some((wallet) => wallet.id === defaultWalletId)
321
+ : '';
322
+ const defaultWalletSelection =
323
+ defaultWalletId && wallets.some((wallet) => wallet.id === defaultWalletId)
243
324
  ? defaultWalletId
244
- : '') ||
325
+ : '';
326
+ const nextWalletSelection =
327
+ (preferInitialWalletSelection
328
+ ? contextualWalletSelection ||
329
+ rememberedWalletId ||
330
+ defaultWalletSelection
331
+ : rememberedWalletId ||
332
+ contextualWalletSelection ||
333
+ defaultWalletSelection) ||
245
334
  wallets[0]?.id ||
246
335
  '';
247
- const sourceLabel = rememberedWalletId
248
- ? t('transaction-data-table.prefill_source_last_used')
249
- : contextualWalletId && nextWalletSelection === contextualWalletId
250
- ? t('transaction-data-table.prefill_source_current_context')
251
- : defaultWalletId && nextWalletSelection === defaultWalletId
252
- ? t('transaction-data-table.prefill_source_workspace_default')
253
- : '';
336
+ const sourceLabel =
337
+ rememberedWalletId && nextWalletSelection === rememberedWalletId
338
+ ? t('transaction-data-table.prefill_source_last_used')
339
+ : contextualWalletSelection &&
340
+ nextWalletSelection === contextualWalletSelection
341
+ ? t('transaction-data-table.prefill_source_current_context')
342
+ : defaultWalletSelection &&
343
+ nextWalletSelection === defaultWalletSelection
344
+ ? t('transaction-data-table.prefill_source_workspace_default')
345
+ : '';
254
346
 
255
347
  if (!nextWalletSelection) return;
256
348
 
@@ -277,6 +369,7 @@ export function TransactionForm({
277
369
  isLastSelectionsInitialized,
278
370
  isLoadingRememberLastSelections,
279
371
  lastSelections.walletId,
372
+ preferInitialWalletSelection,
280
373
  rememberLastSelections,
281
374
  t,
282
375
  wallets,
@@ -430,7 +523,7 @@ export function TransactionForm({
430
523
  const refreshTransactions = async () => {
431
524
  await invalidateTransactionMutationQueries(queryClient, wsId);
432
525
 
433
- if (!onFinish) {
526
+ if (refreshPageOnFinish || !onFinish) {
434
527
  router.refresh();
435
528
  }
436
529
  };
@@ -981,6 +1074,9 @@ export function TransactionForm({
981
1074
  suggestedExchangeRate={suggestedExchangeRate}
982
1075
  isDestinationOverridden={isDestinationOverridden}
983
1076
  setIsDestinationOverridden={setIsDestinationOverridden}
1077
+ includeTakenAtTime={includeTakenAtTime}
1078
+ setIncludeTakenAtTime={setIncludeTakenAtTime}
1079
+ timezone={timezone}
984
1080
  setNewContentType={setNewContentType}
985
1081
  setNewContent={setNewContent}
986
1082
  walletPrefillMeta={walletPrefillMeta}
@@ -55,6 +55,7 @@ import {
55
55
  type FinancePermissionRequestUser,
56
56
  FinancePermissionWarningDialog,
57
57
  } from '@tuturuuu/ui/finance/shared/finance-permission-warning-dialog';
58
+ import { useRouter } from 'next/navigation';
58
59
  import { useLocale, useTranslations } from 'next-intl';
59
60
  import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
60
61
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -71,6 +72,7 @@ import {
71
72
  listTransactionPeriodsWithInternalApi,
72
73
  } from './internal-api';
73
74
  import { PeriodBreakdownPanel } from './period-charts';
75
+ import { invalidateTransactionMutationQueries } from './query-invalidation';
74
76
  import { TransactionCard } from './transaction-card';
75
77
  import { TransactionStatistics } from './transaction-statistics';
76
78
  import { mergeLinkedTransferTransactions } from './transfer-merge';
@@ -144,6 +146,7 @@ export function InfiniteTransactionsList({
144
146
  const t = useTranslations();
145
147
  const locale = useLocale();
146
148
  const queryClient = useQueryClient();
149
+ const router = useRouter();
147
150
  const loadMoreRef = useRef<HTMLDivElement>(null);
148
151
  const { data: exchangeRatesData } = useExchangeRates();
149
152
  const exchangeRates = exchangeRatesData?.data ?? [];
@@ -181,12 +184,11 @@ export function InfiniteTransactionsList({
181
184
  };
182
185
 
183
186
  const handleTransactionUpdate = () => {
184
- queryClient.invalidateQueries({
185
- predicate: (query) =>
186
- Array.isArray(query.queryKey) &&
187
- typeof query.queryKey[0] === 'string' &&
188
- query.queryKey[0].includes(`/api/workspaces/${wsId}/transactions`),
189
- });
187
+ void invalidateTransactionMutationQueries(queryClient, wsId);
188
+
189
+ if (walletId) {
190
+ router.refresh();
191
+ }
190
192
  };
191
193
 
192
194
  const deleteMutation = useMutation({
@@ -1065,6 +1067,8 @@ export function InfiniteTransactionsList({
1065
1067
  canUpdateConfidentialTransactions={
1066
1068
  canUpdateConfidentialTransactions
1067
1069
  }
1070
+ timezone={resolvedTimezone}
1071
+ refreshPageOnFinish={!!walletId}
1068
1072
  permissionRequestUser={permissionRequestUser}
1069
1073
  onFinish={() => {
1070
1074
  handleTransactionUpdate();
@@ -1098,6 +1102,9 @@ export function InfiniteTransactionsList({
1098
1102
  canCreateConfidentialTransactions={
1099
1103
  canCreateConfidentialTransactions
1100
1104
  }
1105
+ timezone={resolvedTimezone}
1106
+ preferInitialWalletSelection={!!walletId}
1107
+ refreshPageOnFinish={!!walletId}
1101
1108
  permissionRequestUser={permissionRequestUser}
1102
1109
  onFinish={() => {
1103
1110
  handleTransactionUpdate();
@@ -85,12 +85,6 @@ export function TransactionCard({
85
85
  useFinanceConfidentialVisibility();
86
86
  const isTransfer = !!transaction.transfer;
87
87
 
88
- // Check if transaction is confidential
89
- const isConfidential =
90
- transaction.is_amount_confidential ||
91
- transaction.is_description_confidential ||
92
- transaction.is_category_confidential;
93
-
94
88
  // Get custom icon if available
95
89
  const CategoryIcon = useMemo(() => {
96
90
  if (transaction.category_icon) {
@@ -127,10 +121,17 @@ export function TransactionCard({
127
121
  return wallets.find((w) => w.id === transaction.transfer?.linked_wallet_id);
128
122
  }, [transaction.transfer?.linked_wallet_id, wallets]);
129
123
 
124
+ const linkedAmount = transaction.transfer?.linked_amount_redacted
125
+ ? undefined
126
+ : transaction.transfer?.linked_amount;
127
+ const linkedAmountIsConfidential = Boolean(
128
+ transaction.transfer?.linked_is_amount_confidential
129
+ );
130
130
  const transferDisplay = transaction.transfer
131
131
  ? transaction.transfer.is_origin
132
132
  ? {
133
133
  amount: transaction.amount,
134
+ amountIsConfidential: Boolean(transaction.is_amount_confidential),
134
135
  amountCurrency: transaction.wallet_currency,
135
136
  destinationIcon: linkedWallet?.icon,
136
137
  destinationImageSrc: linkedWallet?.image_src,
@@ -140,11 +141,15 @@ export function TransactionCard({
140
141
  originImageSrc: wallet?.image_src,
141
142
  originWalletId: transaction.wallet_id,
142
143
  originWalletName: transaction.wallet,
143
- secondaryAmount: transaction.transfer.linked_amount,
144
+ secondaryAmount: linkedAmount,
144
145
  secondaryCurrency: transaction.transfer.linked_wallet_currency,
145
146
  }
146
147
  : {
147
- amount: transaction.transfer.linked_amount ?? transaction.amount,
148
+ amount: linkedAmount ?? transaction.amount,
149
+ amountIsConfidential:
150
+ linkedAmount != null
151
+ ? linkedAmountIsConfidential
152
+ : Boolean(transaction.is_amount_confidential),
148
153
  amountCurrency:
149
154
  transaction.transfer.linked_wallet_currency ||
150
155
  transaction.wallet_currency,
@@ -161,9 +166,16 @@ export function TransactionCard({
161
166
  }
162
167
  : null;
163
168
  const displayAmount = transferDisplay?.amount ?? transaction.amount;
169
+ const displayAmountIsConfidential =
170
+ transferDisplay?.amountIsConfidential ??
171
+ Boolean(transaction.is_amount_confidential);
164
172
  const effectiveCurrency =
165
173
  transferDisplay?.amountCurrency || transaction.wallet_currency || currency;
166
174
  const isExpense = (displayAmount || 0) < 0;
175
+ const isConfidential =
176
+ displayAmountIsConfidential ||
177
+ transaction.is_description_confidential ||
178
+ transaction.is_category_confidential;
167
179
 
168
180
  // Currency conversion for foreign-currency transactions
169
181
  const isForeignCurrency =
@@ -434,7 +446,7 @@ export function TransactionCard({
434
446
  <div className="flex flex-col items-end">
435
447
  <ConfidentialAmount
436
448
  amount={displayAmount ?? null}
437
- isConfidential={transaction.is_amount_confidential || false}
449
+ isConfidential={displayAmountIsConfidential}
438
450
  currency={effectiveCurrency}
439
451
  className={cn(
440
452
  'font-bold text-sm tabular-nums transition-all duration-200 sm:text-xl',
@@ -1,5 +1,6 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
2
  import { render, screen } from '@testing-library/react';
3
+ import type { ComponentProps } from 'react';
3
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { TransactionEditDialog } from './transaction-edit-dialog';
5
6
 
@@ -40,7 +41,9 @@ vi.mock('next/image', () => ({
40
41
  default: () => null,
41
42
  }));
42
43
 
43
- function renderDialog() {
44
+ function renderDialog(
45
+ props: Partial<ComponentProps<typeof TransactionEditDialog>> = {}
46
+ ) {
44
47
  const queryClient = new QueryClient({
45
48
  defaultOptions: {
46
49
  queries: {
@@ -68,6 +71,7 @@ function renderDialog() {
68
71
  } as never
69
72
  }
70
73
  wsId="ws-1"
74
+ {...props}
71
75
  />
72
76
  </QueryClientProvider>
73
77
  );
@@ -98,4 +102,24 @@ describe('TransactionEditDialog', () => {
98
102
  expect(await screen.findByText('•••••')).toBeVisible();
99
103
  expect(screen.queryByText('+$123')).not.toBeInTheDocument();
100
104
  });
105
+
106
+ it('enables the optional time control for existing transactions', async () => {
107
+ renderDialog();
108
+
109
+ expect(
110
+ await screen.findByRole('switch', {
111
+ name: 'transaction-data-table.include_time',
112
+ })
113
+ ).toHaveAttribute('data-state', 'checked');
114
+ });
115
+
116
+ it('disables the optional time control when update access is missing', async () => {
117
+ renderDialog({ canUpdateTransactions: false });
118
+
119
+ expect(
120
+ await screen.findByRole('switch', {
121
+ name: 'transaction-data-table.include_time',
122
+ })
123
+ ).toBeDisabled();
124
+ });
101
125
  });
@@ -50,7 +50,6 @@ import {
50
50
  getIconComponentByKey,
51
51
  type PlatformIconKey,
52
52
  } from '@tuturuuu/ui/custom/icon-picker';
53
- import { DateTimePicker } from '@tuturuuu/ui/date-time-picker';
54
53
  import {
55
54
  Dialog,
56
55
  DialogContent,
@@ -58,6 +57,7 @@ import {
58
57
  DialogTitle,
59
58
  } from '@tuturuuu/ui/dialog';
60
59
  import { Label } from '@tuturuuu/ui/label';
60
+ import { OptionalTimePicker } from '@tuturuuu/ui/optional-time-picker';
61
61
  import { Separator } from '@tuturuuu/ui/separator';
62
62
  import { toast } from '@tuturuuu/ui/sonner';
63
63
  import { Switch } from '@tuturuuu/ui/switch';
@@ -148,6 +148,7 @@ interface TransactionEditDialogProps {
148
148
  canViewConfidentialAmount?: boolean;
149
149
  canViewConfidentialDescription?: boolean;
150
150
  canViewConfidentialCategory?: boolean;
151
+ timezone?: string | null;
151
152
  }
152
153
 
153
154
  export function TransactionEditDialog({
@@ -164,6 +165,7 @@ export function TransactionEditDialog({
164
165
  canViewConfidentialAmount,
165
166
  canViewConfidentialDescription,
166
167
  canViewConfidentialCategory,
168
+ timezone,
167
169
  }: TransactionEditDialogProps) {
168
170
  const t = useTranslations();
169
171
  const locale = useLocale();
@@ -212,6 +214,9 @@ export function TransactionEditDialog({
212
214
  const [takenAt, setTakenAt] = useState<Date | undefined>(
213
215
  transaction?.taken_at ? new Date(transaction.taken_at) : new Date()
214
216
  );
217
+ const [includeTakenAtTime, setIncludeTakenAtTime] = useState(
218
+ !!transaction?.taken_at
219
+ );
215
220
  const [reportOptIn, setReportOptIn] = useState(
216
221
  transaction?.report_opt_in ?? true
217
222
  );
@@ -292,6 +297,7 @@ export function TransactionEditDialog({
292
297
  setTakenAt(
293
298
  transaction.taken_at ? new Date(transaction.taken_at) : new Date()
294
299
  );
300
+ setIncludeTakenAtTime(!!transaction.taken_at);
295
301
  setReportOptIn(transaction.report_opt_in ?? true);
296
302
  setIsAmountConfidential(
297
303
  (transaction as any)?.is_amount_confidential ?? false
@@ -309,6 +315,7 @@ export function TransactionEditDialog({
309
315
  setWalletId('');
310
316
  setCategoryId('');
311
317
  setTakenAt(new Date());
318
+ setIncludeTakenAtTime(true);
312
319
  setReportOptIn(true);
313
320
  setIsAmountConfidential(false);
314
321
  setIsDescriptionConfidential(false);
@@ -748,13 +755,19 @@ export function TransactionEditDialog({
748
755
  </div>
749
756
  {t('transaction-data-table.taken_at')}
750
757
  </Label>
751
- <DateTimePicker
758
+ <OptionalTimePicker
752
759
  date={takenAt}
753
760
  setDate={setTakenAt}
754
- showTimeSelect={true}
761
+ includeTime={includeTakenAtTime}
762
+ setIncludeTime={setIncludeTakenAtTime}
763
+ includeTimeLabel={t('transaction-data-table.include_time')}
755
764
  allowClear={false}
756
765
  showFooterControls={true}
757
766
  disabled={isDisabled || !canUpdateTransactions}
767
+ preferences={{
768
+ timezone: timezone || 'auto',
769
+ timeFormat: locale === 'vi' ? '24h' : '12h',
770
+ }}
758
771
  />
759
772
  </div>
760
773
 
@@ -34,6 +34,7 @@ interface Props {
34
34
  currency?: string;
35
35
  transaction: any;
36
36
  tags: Array<{ id: string; name: string; color: string }>;
37
+ timezone?: string | null;
37
38
  }
38
39
 
39
40
  export function TransactionDetailsClientPage({
@@ -41,6 +42,7 @@ export function TransactionDetailsClientPage({
41
42
  currency = 'USD',
42
43
  transaction,
43
44
  tags,
45
+ timezone,
44
46
  }: Props) {
45
47
  const t = useTranslations();
46
48
  const financeHref = useFinanceHref();
@@ -289,6 +291,7 @@ export function TransactionDetailsClientPage({
289
291
  wsId={wsId}
290
292
  isOpen={isEditOpen}
291
293
  onClose={() => setIsEditOpen(false)}
294
+ timezone={timezone}
292
295
  />
293
296
  </>
294
297
  );