@tuturuuu/ui 0.4.1 → 0.6.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 (107) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +126 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +146 -0
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  22. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  23. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  24. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  25. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +219 -0
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  28. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  29. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  30. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  31. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +197 -0
  32. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  33. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +541 -0
  34. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +362 -0
  35. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  36. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  37. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  38. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  39. package/src/components/ui/finance/wallets/form.tsx +41 -197
  40. package/src/components/ui/finance/wallets/query-invalidation.ts +3 -0
  41. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  42. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  43. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  48. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +71 -5
  49. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +52 -35
  50. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  51. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  52. package/src/components/ui/finance/wallets/wallets-page.test.tsx +117 -36
  53. package/src/components/ui/finance/wallets/wallets-page.tsx +40 -64
  54. package/src/components/ui/storefront/accent-button.tsx +33 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  56. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  58. package/src/components/ui/storefront/image-panel.tsx +40 -0
  59. package/src/components/ui/storefront/index.ts +12 -0
  60. package/src/components/ui/storefront/listing-card.tsx +129 -0
  61. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  62. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  63. package/src/components/ui/storefront/types.ts +99 -0
  64. package/src/components/ui/storefront/utils.ts +90 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  67. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  68. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  69. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  70. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  71. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  72. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  73. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  74. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  75. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +124 -7
  76. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  77. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  79. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  80. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  81. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  83. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +268 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +243 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +41 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +157 -102
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  100. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +959 -340
  101. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  102. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  103. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  104. package/src/hooks/use-task-actions.ts +45 -0
  105. package/src/hooks/useBoardRealtime.ts +54 -1
  106. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  107. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -0,0 +1,171 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import type { ReactElement } from 'react';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { WalletsDataTable } from './wallets-data-table';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ dataTableProps: undefined as
9
+ | {
10
+ data?: Array<{ id: string; name?: string | null }>;
11
+ onRefresh?: () => void;
12
+ onSearch?: (query: string) => void;
13
+ }
14
+ | undefined,
15
+ listInfiniteWallets: vi.fn(),
16
+ replace: vi.fn(),
17
+ searchParams: new URLSearchParams(),
18
+ }));
19
+
20
+ vi.mock('@tuturuuu/internal-api/finance', () => ({
21
+ listInfiniteWallets: (
22
+ ...args: Parameters<typeof mocks.listInfiniteWallets>
23
+ ) => mocks.listInfiniteWallets(...args),
24
+ }));
25
+
26
+ vi.mock('@tuturuuu/ui/custom/modifiable-dialog-trigger', () => ({
27
+ default: () => null,
28
+ }));
29
+
30
+ vi.mock('@tuturuuu/ui/custom/tables/data-table', () => ({
31
+ DataTable: (props: {
32
+ data?: Array<{ id: string; name?: string | null }>;
33
+ onRefresh?: () => void;
34
+ onSearch?: (query: string) => void;
35
+ }) => {
36
+ mocks.dataTableProps = props;
37
+
38
+ return (
39
+ <div data-testid="wallet-table">
40
+ {(props.data ?? []).map((wallet) => (
41
+ <div key={wallet.id}>{wallet.name}</div>
42
+ ))}
43
+ </div>
44
+ );
45
+ },
46
+ }));
47
+
48
+ vi.mock('@tuturuuu/ui/finance/wallets/columns', () => ({
49
+ walletColumns: vi.fn(),
50
+ }));
51
+
52
+ vi.mock('@tuturuuu/ui/finance/wallets/form', () => ({
53
+ WalletForm: () => null,
54
+ }));
55
+
56
+ vi.mock('@tuturuuu/ui/hooks/use-exchange-rates', () => ({
57
+ useExchangeRates: () => ({
58
+ data: {
59
+ data: [],
60
+ },
61
+ }),
62
+ }));
63
+
64
+ vi.mock('next/navigation', () => ({
65
+ usePathname: () => '/ws-1/finance/wallets',
66
+ useRouter: () => ({
67
+ replace: (...args: Parameters<typeof mocks.replace>) =>
68
+ mocks.replace(...args),
69
+ }),
70
+ useSearchParams: () => mocks.searchParams,
71
+ }));
72
+
73
+ vi.mock('next-intl', () => ({
74
+ useTranslations:
75
+ () => (key: string, values?: Record<string, string | number>) =>
76
+ values
77
+ ? `${key}:${Object.values(values)
78
+ .map((value) => String(value))
79
+ .join(',')}`
80
+ : key,
81
+ }));
82
+
83
+ vi.mock('../shared/use-finance-balance-mode', () => ({
84
+ useFinanceBalanceMode: () => ({
85
+ mode: 'audited',
86
+ }),
87
+ }));
88
+
89
+ function renderWithQueryClient(ui: ReactElement) {
90
+ const queryClient = new QueryClient({
91
+ defaultOptions: {
92
+ queries: {
93
+ retry: false,
94
+ },
95
+ },
96
+ });
97
+
98
+ render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
99
+ }
100
+
101
+ describe('wallets data table infinite loading', () => {
102
+ beforeEach(() => {
103
+ vi.clearAllMocks();
104
+ mocks.dataTableProps = undefined;
105
+ mocks.searchParams = new URLSearchParams();
106
+
107
+ globalThis.IntersectionObserver = class IntersectionObserver {
108
+ disconnect() {}
109
+ observe() {}
110
+ takeRecords() {
111
+ return [];
112
+ }
113
+ unobserve() {}
114
+ } as unknown as typeof IntersectionObserver;
115
+
116
+ mocks.listInfiniteWallets.mockImplementation(
117
+ async (
118
+ _workspaceId: string,
119
+ query: {
120
+ offset?: number;
121
+ }
122
+ ) => {
123
+ if (query.offset === 20) {
124
+ return {
125
+ data: [{ id: 'wallet-2', name: 'Bank' }],
126
+ hasMore: false,
127
+ nextOffset: null,
128
+ totalCount: 2,
129
+ };
130
+ }
131
+
132
+ return {
133
+ data: [{ id: 'wallet-1', name: 'Cash' }],
134
+ hasMore: true,
135
+ nextOffset: 20,
136
+ totalCount: 2,
137
+ };
138
+ }
139
+ );
140
+ });
141
+
142
+ it('loads the next server page from the fallback load-more button', async () => {
143
+ renderWithQueryClient(
144
+ <WalletsDataTable wsId="ws-1" currency="USD" query="bank" />
145
+ );
146
+
147
+ expect(await screen.findByText('Cash')).toBeInTheDocument();
148
+
149
+ fireEvent.click(
150
+ screen.getByRole('button', { name: 'wallet-data-table.load_more' })
151
+ );
152
+
153
+ expect(await screen.findByText('Bank')).toBeInTheDocument();
154
+ expect(mocks.listInfiniteWallets).toHaveBeenCalledWith('ws-1', {
155
+ limit: 20,
156
+ offset: 20,
157
+ q: 'bank',
158
+ });
159
+ });
160
+
161
+ it('keeps search in the URL and drops legacy page params', async () => {
162
+ mocks.searchParams = new URLSearchParams('page=2&pageSize=50');
163
+
164
+ renderWithQueryClient(<WalletsDataTable wsId="ws-1" currency="USD" />);
165
+
166
+ await waitFor(() => expect(mocks.dataTableProps).toBeDefined());
167
+ mocks.dataTableProps?.onSearch?.(' cash ');
168
+
169
+ expect(mocks.replace).toHaveBeenCalledWith('/ws-1/finance/wallets?q=cash');
170
+ });
171
+ });
@@ -1,7 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useQueryClient } from '@tanstack/react-query';
3
+ import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { ChevronDown, Loader2 } from '@tuturuuu/icons';
5
+ import { listInfiniteWallets } from '@tuturuuu/internal-api/finance';
4
6
  import type { Wallet } from '@tuturuuu/types/primitives/Wallet';
7
+ import { Button } from '@tuturuuu/ui/button';
5
8
  import ModifiableDialogTrigger from '@tuturuuu/ui/custom/modifiable-dialog-trigger';
6
9
  import { DataTable } from '@tuturuuu/ui/custom/tables/data-table';
7
10
  import { walletColumns } from '@tuturuuu/ui/finance/wallets/columns';
@@ -9,32 +12,29 @@ import { WalletForm } from '@tuturuuu/ui/finance/wallets/form';
9
12
  import { useExchangeRates } from '@tuturuuu/ui/hooks/use-exchange-rates';
10
13
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
11
14
  import { useTranslations } from 'next-intl';
12
- import { type ReactNode, useCallback, useState } from 'react';
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+ import { useFinanceBalanceMode } from '../shared/use-finance-balance-mode';
17
+
18
+ const WALLET_PAGE_SIZE = 20;
13
19
 
14
20
  interface WalletsDataTableProps {
15
21
  wsId: string;
16
- data: Wallet[];
17
- count: number;
18
- filters?: ReactNode[];
19
22
  canUpdateWallets?: boolean;
20
23
  canDeleteWallets?: boolean;
21
24
  currency?: string;
25
+ financePrefix?: string;
22
26
  isPersonalWorkspace?: boolean;
23
- page?: string;
24
- pageSize?: string;
27
+ query?: string;
25
28
  }
26
29
 
27
30
  export function WalletsDataTable({
28
31
  wsId,
29
- data,
30
- count,
31
- filters,
32
32
  canUpdateWallets,
33
33
  canDeleteWallets,
34
34
  currency = 'USD',
35
+ financePrefix = '/finance',
35
36
  isPersonalWorkspace,
36
- page,
37
- pageSize,
37
+ query,
38
38
  }: WalletsDataTableProps) {
39
39
  const t = useTranslations();
40
40
  const router = useRouter();
@@ -42,6 +42,32 @@ export function WalletsDataTable({
42
42
  const searchParams = useSearchParams();
43
43
  const queryClient = useQueryClient();
44
44
  const { data: exchangeRatesResponse } = useExchangeRates();
45
+ const { mode: balanceMode } = useFinanceBalanceMode();
46
+ const loadMoreRef = useRef<HTMLDivElement | null>(null);
47
+ const normalizedQuery = query?.trim() ?? '';
48
+ const walletsQuery = useInfiniteQuery({
49
+ queryKey: ['wallets', wsId, 'infinite', normalizedQuery, WALLET_PAGE_SIZE],
50
+ queryFn: ({ pageParam }) =>
51
+ listInfiniteWallets(wsId, {
52
+ limit: WALLET_PAGE_SIZE,
53
+ offset: pageParam,
54
+ q: normalizedQuery,
55
+ }),
56
+ getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined,
57
+ initialPageParam: 0,
58
+ });
59
+ const wallets = useMemo(
60
+ () =>
61
+ (walletsQuery.data?.pages.flatMap((page) => page.data) ?? []).map(
62
+ (wallet) => ({
63
+ ...wallet,
64
+ href: `/${wsId}${financePrefix}/wallets/${wallet.id}`,
65
+ ws_id: wsId,
66
+ })
67
+ ),
68
+ [financePrefix, walletsQuery.data?.pages, wsId]
69
+ );
70
+ const totalCount = walletsQuery.data?.pages.at(0)?.totalCount ?? 0;
45
71
 
46
72
  // State for edit dialog
47
73
  const [selectedWallet, setSelectedWallet] = useState<Wallet | null>(null);
@@ -63,42 +89,73 @@ export function WalletsDataTable({
63
89
  });
64
90
  }, [queryClient, wsId]);
65
91
 
66
- const handleSetParams = useCallback(
67
- (params: { page?: number; pageSize?: string }) => {
92
+ const updateSearchQuery = useCallback(
93
+ (nextQuery: string) => {
68
94
  const newSearchParams = new URLSearchParams(searchParams?.toString());
95
+ const trimmedQuery = nextQuery.trim();
69
96
 
70
- if (params.page !== undefined) {
71
- newSearchParams.set('page', params.page.toString());
72
- }
73
-
74
- if (params.pageSize !== undefined) {
75
- newSearchParams.set('pageSize', params.pageSize);
97
+ if (trimmedQuery) {
98
+ newSearchParams.set('q', trimmedQuery);
99
+ } else {
100
+ newSearchParams.delete('q');
76
101
  }
102
+ newSearchParams.delete('page');
103
+ newSearchParams.delete('pageSize');
77
104
 
78
- router.push(`${pathname}?${newSearchParams.toString()}`);
105
+ const queryString = newSearchParams.toString();
106
+ router.replace(queryString ? `${pathname}?${queryString}` : pathname);
79
107
  },
80
108
  [pathname, router, searchParams]
81
109
  );
82
110
 
83
- const pageIndex = page ? Number.parseInt(page, 10) - 1 : 0;
84
- const pageSizeValue = pageSize ? Number.parseInt(pageSize, 10) : 10;
111
+ const resetSearchQuery = useCallback(() => {
112
+ updateSearchQuery('');
113
+ }, [updateSearchQuery]);
114
+
115
+ useEffect(() => {
116
+ const target = loadMoreRef.current;
117
+ if (
118
+ !target ||
119
+ !walletsQuery.hasNextPage ||
120
+ walletsQuery.isFetchingNextPage
121
+ ) {
122
+ return;
123
+ }
124
+
125
+ const observer = new IntersectionObserver(
126
+ (entries) => {
127
+ if (entries[0]?.isIntersecting) {
128
+ void walletsQuery.fetchNextPage();
129
+ }
130
+ },
131
+ { rootMargin: '160px' }
132
+ );
133
+
134
+ observer.observe(target);
135
+
136
+ return () => observer.disconnect();
137
+ }, [
138
+ walletsQuery.fetchNextPage,
139
+ walletsQuery.hasNextPage,
140
+ walletsQuery.isFetchingNextPage,
141
+ ]);
85
142
 
86
143
  return (
87
144
  <div className="relative">
88
145
  <DataTable
89
146
  t={t}
90
- data={data}
147
+ data={walletsQuery.isLoading ? undefined : wallets}
91
148
  columnGenerator={walletColumns}
92
149
  extraData={{
150
+ balanceMode,
93
151
  canUpdateWallets,
94
152
  canDeleteWallets,
95
153
  currency,
96
154
  exchangeRates: exchangeRatesResponse?.data,
97
155
  isPersonalWorkspace,
98
156
  }}
99
- filters={filters}
100
157
  namespace="wallet-data-table"
101
- count={count}
158
+ defaultQuery={normalizedQuery}
102
159
  defaultVisibility={{
103
160
  id: false,
104
161
  description: false,
@@ -107,12 +164,58 @@ export function WalletsDataTable({
107
164
  report_opt_in: false,
108
165
  created_at: false,
109
166
  }}
167
+ hidePagination
168
+ isFiltered={normalizedQuery.length > 0}
169
+ onRefresh={() => void walletsQuery.refetch()}
110
170
  onRowClick={canUpdateWallets ? handleRowClick : undefined}
111
- pageIndex={pageIndex}
112
- pageSize={pageSizeValue}
113
- setParams={handleSetParams}
171
+ onSearch={updateSearchQuery}
172
+ resetParams={resetSearchQuery}
114
173
  />
115
174
 
175
+ {!walletsQuery.isLoading && walletsQuery.isError && (
176
+ <div className="rounded-lg border border-dynamic-red/30 bg-dynamic-red/5 p-4 text-dynamic-red text-sm">
177
+ {walletsQuery.error instanceof Error
178
+ ? walletsQuery.error.message
179
+ : t('wallet-data-table.load_error')}
180
+ </div>
181
+ )}
182
+
183
+ {!walletsQuery.isLoading && !walletsQuery.isError && (
184
+ <div
185
+ ref={loadMoreRef}
186
+ className="mt-4 flex flex-col items-center gap-3"
187
+ >
188
+ <div className="text-muted-foreground text-sm">
189
+ {t('wallet-data-table.loaded_count', {
190
+ loaded: wallets.length,
191
+ total: totalCount,
192
+ })}
193
+ </div>
194
+ {walletsQuery.isFetchingNextPage && (
195
+ <div className="flex items-center gap-2 text-muted-foreground text-sm">
196
+ <Loader2 className="h-4 w-4 animate-spin" />
197
+ {t('wallet-data-table.loading_more')}
198
+ </div>
199
+ )}
200
+ {walletsQuery.hasNextPage && !walletsQuery.isFetchingNextPage && (
201
+ <Button
202
+ type="button"
203
+ variant="outline"
204
+ size="sm"
205
+ onClick={() => void walletsQuery.fetchNextPage()}
206
+ >
207
+ <ChevronDown className="mr-2 h-4 w-4" />
208
+ {t('wallet-data-table.load_more')}
209
+ </Button>
210
+ )}
211
+ {!walletsQuery.hasNextPage && wallets.length > WALLET_PAGE_SIZE && (
212
+ <div className="rounded-lg border border-dashed bg-muted/20 px-4 py-2 text-muted-foreground text-sm">
213
+ {t('wallet-data-table.end_of_list')}
214
+ </div>
215
+ )}
216
+ </div>
217
+ )}
218
+
116
219
  {/* Edit Dialog */}
117
220
  {selectedWallet && (
118
221
  <ModifiableDialogTrigger
@@ -1,24 +1,20 @@
1
+ import { render } from '@testing-library/react';
2
+ import type { ReactElement } from 'react';
1
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
4
 
3
5
  const mocks = vi.hoisted(() => ({
6
+ createDialogFeatureSummary: vi.fn((_props: unknown) => null),
4
7
  getPermissions: vi.fn(),
5
8
  getTranslations: vi.fn(),
6
9
  getWorkspace: vi.fn(),
7
10
  getWorkspaceConfig: vi.fn(),
8
- headers: vi.fn(),
9
- listWallets: vi.fn(),
10
11
  notFound: vi.fn(() => {
11
12
  throw new Error('notFound');
12
13
  }),
13
- withForwardedInternalApiAuth: vi.fn(() => ({ headers: {} })),
14
- }));
15
-
16
- vi.mock('@tuturuuu/internal-api', () => ({
17
- listWallets: (...args: Parameters<typeof mocks.listWallets>) =>
18
- mocks.listWallets(...args),
19
- withForwardedInternalApiAuth: (
20
- ...args: Parameters<typeof mocks.withForwardedInternalApiAuth>
21
- ) => mocks.withForwardedInternalApiAuth(...args),
14
+ walletCheckpointHistoryDialog: vi.fn((_props: unknown) => null),
15
+ walletForm: vi.fn((_props: unknown) => null),
16
+ walletTotalCheckDialog: vi.fn((_props: unknown) => null),
17
+ walletsDataTable: vi.fn((_props: unknown) => null),
22
18
  }));
23
19
 
24
20
  vi.mock('@tuturuuu/utils/workspace-helper', () => ({
@@ -30,11 +26,6 @@ vi.mock('@tuturuuu/utils/workspace-helper', () => ({
30
26
  mocks.getWorkspaceConfig(...args),
31
27
  }));
32
28
 
33
- vi.mock('next/headers', () => ({
34
- headers: (...args: Parameters<typeof mocks.headers>) =>
35
- mocks.headers(...args),
36
- }));
37
-
38
29
  vi.mock('next/navigation', () => ({
39
30
  notFound: (...args: Parameters<typeof mocks.notFound>) =>
40
31
  mocks.notFound(...args),
@@ -50,15 +41,45 @@ vi.mock('@tuturuuu/ui/custom/feature-summary', () => ({
50
41
  }));
51
42
 
52
43
  vi.mock('@tuturuuu/ui/finance/shared/create-dialog-feature-summary', () => ({
53
- CreateDialogFeatureSummary: () => null,
44
+ CreateDialogFeatureSummary: (
45
+ ...args: Parameters<typeof mocks.createDialogFeatureSummary>
46
+ ) => mocks.createDialogFeatureSummary(...args),
47
+ }));
48
+
49
+ vi.mock('@tuturuuu/ui/finance/shared/balance-mode-toggle', () => ({
50
+ FinanceBalanceModeToggle: () => null,
54
51
  }));
55
52
 
53
+ vi.mock('@tuturuuu/ui/finance/shared/numbers-visibility-toggle', () => ({
54
+ FinanceNumbersVisibilityToggle: () => null,
55
+ }));
56
+
57
+ vi.mock(
58
+ '@tuturuuu/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog',
59
+ () => ({
60
+ WalletCheckpointHistoryDialog: (
61
+ ...args: Parameters<typeof mocks.walletCheckpointHistoryDialog>
62
+ ) => mocks.walletCheckpointHistoryDialog(...args),
63
+ })
64
+ );
65
+
66
+ vi.mock(
67
+ '@tuturuuu/ui/finance/wallets/checkpoints/wallet-total-check-dialog',
68
+ () => ({
69
+ WalletTotalCheckDialog: (
70
+ ...args: Parameters<typeof mocks.walletTotalCheckDialog>
71
+ ) => mocks.walletTotalCheckDialog(...args),
72
+ })
73
+ );
74
+
56
75
  vi.mock('@tuturuuu/ui/finance/wallets/form', () => ({
57
- WalletForm: () => null,
76
+ WalletForm: (...args: Parameters<typeof mocks.walletForm>) =>
77
+ mocks.walletForm(...args),
58
78
  }));
59
79
 
60
80
  vi.mock('@tuturuuu/ui/finance/wallets/wallets-data-table', () => ({
61
- WalletsDataTable: () => null,
81
+ WalletsDataTable: (...args: Parameters<typeof mocks.walletsDataTable>) =>
82
+ mocks.walletsDataTable(...args),
62
83
  }));
63
84
 
64
85
  vi.mock('@tuturuuu/ui/separator', () => ({
@@ -72,36 +93,96 @@ describe('wallets page', () => {
72
93
  mocks.getTranslations.mockResolvedValue((key: string) => key);
73
94
  mocks.getPermissions.mockResolvedValue({
74
95
  containsPermission: vi.fn((permission: string) =>
75
- ['create_wallets', 'update_wallets', 'delete_wallets'].includes(
76
- permission
77
- )
96
+ [
97
+ 'create_transactions',
98
+ 'create_wallets',
99
+ 'update_wallets',
100
+ 'delete_wallets',
101
+ ].includes(permission)
78
102
  ),
79
103
  });
80
104
  mocks.getWorkspaceConfig.mockResolvedValue('USD');
81
105
  mocks.getWorkspace.mockResolvedValue({ personal: false });
82
- mocks.headers.mockResolvedValue(new Headers());
83
- mocks.listWallets.mockResolvedValue([
84
- { id: 'wallet-1', name: 'Primary Wallet' },
85
- ]);
86
106
  });
87
107
 
88
- it('loads wallets through the internal API helper', async () => {
108
+ it('passes wallet controls and the search query to the infinite table', async () => {
89
109
  const { default: WalletsPageModule } = await import('./wallets-page.js');
90
110
  const WalletsPage = WalletsPageModule as unknown as (props: {
91
111
  wsId: string;
92
- searchParams: { q: string; page: string; pageSize: string };
93
- page?: string;
94
- pageSize?: string;
112
+ searchParams: { q: string };
95
113
  }) => Promise<unknown>;
96
114
 
97
- await WalletsPage({
115
+ const element = await WalletsPage({
98
116
  wsId: 'ws-1',
99
- searchParams: { q: '', page: '1', pageSize: '10' },
100
- page: '1',
101
- pageSize: '10',
117
+ searchParams: { q: 'bank' },
102
118
  });
103
119
 
104
- expect(mocks.withForwardedInternalApiAuth).toHaveBeenCalled();
105
- expect(mocks.listWallets).toHaveBeenCalledWith('ws-1', { headers: {} });
120
+ render(element as ReactElement);
121
+
122
+ expect(mocks.walletsDataTable).toHaveBeenCalledWith(
123
+ expect.objectContaining({
124
+ canDeleteWallets: true,
125
+ canUpdateWallets: true,
126
+ currency: 'USD',
127
+ financePrefix: '/finance',
128
+ isPersonalWorkspace: false,
129
+ query: 'bank',
130
+ wsId: 'ws-1',
131
+ }),
132
+ undefined
133
+ );
134
+ expect(mocks.walletTotalCheckDialog).toHaveBeenCalledWith(
135
+ expect.objectContaining({
136
+ canUpdateWallets: true,
137
+ currency: 'USD',
138
+ wsId: 'ws-1',
139
+ }),
140
+ undefined
141
+ );
142
+ expect(mocks.walletCheckpointHistoryDialog).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ canCreateTransactions: true,
145
+ financePrefix: '/finance',
146
+ wsId: 'ws-1',
147
+ }),
148
+ undefined
149
+ );
150
+ });
151
+
152
+ it('opens wallet creation in credit-card mode from search params', async () => {
153
+ const { default: WalletsPageModule } = await import('./wallets-page.js');
154
+ const WalletsPage = WalletsPageModule as unknown as (props: {
155
+ wsId: string;
156
+ searchParams: {
157
+ create?: string;
158
+ q: string;
159
+ };
160
+ }) => Promise<ReactElement>;
161
+
162
+ const element = await WalletsPage({
163
+ wsId: 'ws-1',
164
+ searchParams: {
165
+ create: 'credit-card',
166
+ q: '',
167
+ },
168
+ });
169
+
170
+ render(element);
171
+
172
+ const createDialogProps =
173
+ mocks.createDialogFeatureSummary.mock.calls[0]?.[0];
174
+
175
+ expect(createDialogProps).toEqual(
176
+ expect.objectContaining({
177
+ defaultOpen: true,
178
+ })
179
+ );
180
+ const form = (createDialogProps as { form?: ReactElement }).form;
181
+
182
+ expect(form?.props).toEqual(
183
+ expect.objectContaining({
184
+ defaultType: 'CREDIT',
185
+ })
186
+ );
106
187
  });
107
188
  });