@tuturuuu/ui 0.5.0 → 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 (88) hide show
  1. package/CHANGELOG.md +29 -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 +50 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
  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/wallet-filter.tsx +21 -2
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
  28. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  29. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  30. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  31. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  32. package/src/components/ui/finance/wallets/form.tsx +41 -197
  33. package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
  34. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  35. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  36. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  40. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  41. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
  42. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
  43. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  44. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  45. package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
  46. package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
  47. package/src/components/ui/storefront/accent-button.tsx +33 -0
  48. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  49. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  50. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  51. package/src/components/ui/storefront/image-panel.tsx +40 -0
  52. package/src/components/ui/storefront/index.ts +12 -0
  53. package/src/components/ui/storefront/listing-card.tsx +129 -0
  54. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  55. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  56. package/src/components/ui/storefront/types.ts +99 -0
  57. package/src/components/ui/storefront/utils.ts +90 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  59. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  60. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  61. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  62. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  63. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  64. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  65. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
  66. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  67. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  68. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  69. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  70. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  71. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  72. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  73. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  85. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
  86. package/src/hooks/useBoardRealtime.ts +54 -1
  87. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  88. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -1,34 +1,254 @@
1
1
  'use client';
2
2
 
3
+ import {
4
+ BookOpen,
5
+ Scale,
6
+ TrendingDown,
7
+ TrendingUp,
8
+ TriangleAlert,
9
+ } from '@tuturuuu/icons';
10
+ import { Badge } from '@tuturuuu/ui/badge';
11
+ import type { ExchangeRate } from '@tuturuuu/utils/exchange-rates';
12
+ import { convertCurrency } from '@tuturuuu/utils/exchange-rates';
13
+ import { cn, formatCurrency, getCurrencyLocale } from '@tuturuuu/utils/format';
14
+ import { useTranslations } from 'next-intl';
15
+ import type { ReactNode } from 'react';
16
+ import { useFinanceBalanceMode } from '../../shared/use-finance-balance-mode';
3
17
  import {
4
18
  FINANCE_HIDDEN_AMOUNT,
5
19
  useFinanceConfidentialVisibility,
6
20
  } from '../../shared/use-finance-confidential-visibility';
21
+ import {
22
+ getWalletBalanceTone,
23
+ resolveWalletBalanceForMode,
24
+ } from '../../shared/wallet-balance-mode';
7
25
 
8
26
  interface WalletDetailsAmountProps {
27
+ auditedBalance?: number | null;
28
+ auditStatus?: 'clean' | 'no_checkpoint' | 'unresolved' | null;
29
+ auditVariance?: number | null;
30
+ currency?: string;
9
31
  primary: string;
10
32
  converted?: string | null;
33
+ exchangeRates?: ExchangeRate[];
34
+ ledgerBalance?: number | null;
35
+ workspaceCurrency?: string | null;
36
+ }
37
+
38
+ type AmountBadgeTone = ReturnType<typeof getWalletBalanceTone> | 'varied';
39
+
40
+ function getAmountBadgeClassName(tone: AmountBadgeTone) {
41
+ if (tone === 'varied') {
42
+ return 'border-dynamic-orange/40 bg-dynamic-orange/10 font-semibold text-dynamic-orange';
43
+ }
44
+
45
+ if (tone === 'positive') {
46
+ return 'border-dynamic-green/30 bg-dynamic-green/10 font-semibold text-dynamic-green';
47
+ }
48
+
49
+ if (tone === 'negative') {
50
+ return 'border-dynamic-red/30 bg-dynamic-red/10 font-semibold text-dynamic-red';
51
+ }
52
+
53
+ return 'font-semibold text-muted-foreground';
54
+ }
55
+
56
+ function getContextAmountBadgeClassName(tone: 'ledger' | 'variance') {
57
+ if (tone === 'ledger') {
58
+ return 'border-dynamic-blue/30 bg-dynamic-blue/10 font-semibold text-dynamic-blue';
59
+ }
60
+
61
+ return 'border-dynamic-purple/30 bg-dynamic-purple/10 font-semibold text-dynamic-purple';
62
+ }
63
+
64
+ function AmountBadge({
65
+ children,
66
+ icon,
67
+ tone,
68
+ }: {
69
+ children: ReactNode;
70
+ icon?: ReactNode;
71
+ tone: AmountBadgeTone;
72
+ }) {
73
+ return (
74
+ <Badge
75
+ variant="outline"
76
+ data-wallet-details-balance-badge={tone}
77
+ className={cn(
78
+ 'flex w-fit items-center gap-1 whitespace-nowrap px-2 py-1 text-sm',
79
+ getAmountBadgeClassName(tone)
80
+ )}
81
+ >
82
+ {icon}
83
+ <span>{children}</span>
84
+ </Badge>
85
+ );
86
+ }
87
+
88
+ function ContextAmountBadge({
89
+ children,
90
+ icon,
91
+ label,
92
+ tone,
93
+ }: {
94
+ children: ReactNode;
95
+ icon?: ReactNode;
96
+ label: string;
97
+ tone: 'ledger' | 'variance';
98
+ }) {
99
+ return (
100
+ <Badge
101
+ variant="outline"
102
+ className={cn(
103
+ 'flex w-fit items-center gap-1 whitespace-nowrap',
104
+ getContextAmountBadgeClassName(tone)
105
+ )}
106
+ >
107
+ {icon}
108
+ <span className="font-medium opacity-75">{label}</span>
109
+ <span>{children}</span>
110
+ </Badge>
111
+ );
11
112
  }
12
113
 
13
114
  export function WalletDetailsAmount({
115
+ auditedBalance,
116
+ auditStatus,
117
+ auditVariance,
118
+ currency,
14
119
  primary,
15
120
  converted,
121
+ exchangeRates,
122
+ ledgerBalance,
123
+ workspaceCurrency,
16
124
  }: WalletDetailsAmountProps) {
125
+ const t = useTranslations('wallet-checkpoints');
17
126
  const { isConfidential: areNumbersHidden } =
18
127
  useFinanceConfidentialVisibility();
128
+ const { mode } = useFinanceBalanceMode();
19
129
 
20
130
  if (areNumbersHidden) {
21
131
  return (
22
- <span className="text-muted-foreground">{FINANCE_HIDDEN_AMOUNT}</span>
132
+ <Badge variant="outline" className="font-semibold text-muted-foreground">
133
+ {FINANCE_HIDDEN_AMOUNT}
134
+ </Badge>
23
135
  );
24
136
  }
25
137
 
138
+ const {
139
+ auditStatus: resolvedAuditStatus,
140
+ auditVariance: resolvedAuditVariance,
141
+ contextBalance,
142
+ displayBalance,
143
+ hasAuditedBalance,
144
+ isAuditedMode,
145
+ } = resolveWalletBalanceForMode(
146
+ {
147
+ audit_balance: auditedBalance,
148
+ audit_status: auditStatus ?? undefined,
149
+ audit_variance: auditVariance,
150
+ balance: ledgerBalance,
151
+ },
152
+ mode
153
+ );
154
+ const hasResolvableBalance =
155
+ typeof ledgerBalance === 'number' || typeof auditedBalance === 'number';
156
+ const resolvedCurrency = currency ?? 'USD';
157
+ const resolvedWorkspaceCurrency = workspaceCurrency ?? resolvedCurrency;
158
+ const displayPrimary = hasResolvableBalance
159
+ ? Intl.NumberFormat(getCurrencyLocale(resolvedCurrency), {
160
+ style: 'currency',
161
+ currency: resolvedCurrency,
162
+ }).format(displayBalance)
163
+ : primary;
164
+ let displayConverted = converted;
165
+
166
+ if (
167
+ typeof displayBalance === 'number' &&
168
+ resolvedCurrency !== resolvedWorkspaceCurrency &&
169
+ exchangeRates &&
170
+ exchangeRates.length > 0 &&
171
+ displayBalance !== 0
172
+ ) {
173
+ const convertedBalance = convertCurrency(
174
+ displayBalance,
175
+ resolvedCurrency,
176
+ resolvedWorkspaceCurrency,
177
+ exchangeRates
178
+ );
179
+
180
+ if (convertedBalance !== null) {
181
+ displayConverted = formatCurrency(
182
+ Math.abs(convertedBalance),
183
+ resolvedWorkspaceCurrency,
184
+ undefined,
185
+ { signDisplay: 'never', maximumFractionDigits: 0 }
186
+ );
187
+ }
188
+ }
189
+
190
+ const showAuditContext =
191
+ hasAuditedBalance &&
192
+ resolvedAuditStatus &&
193
+ resolvedAuditStatus !== 'clean' &&
194
+ resolvedAuditStatus !== 'no_checkpoint' &&
195
+ resolvedAuditVariance !== null &&
196
+ resolvedAuditVariance !== 0;
197
+ const displayTone = showAuditContext
198
+ ? 'varied'
199
+ : getWalletBalanceTone(displayBalance);
200
+
26
201
  return (
27
- <span>
28
- {primary}
29
- {converted && (
30
- <span className="ml-2 text-muted-foreground text-sm">
31
- {'\u2248'} {converted}
202
+ <span className="inline-flex flex-col items-start gap-1 align-middle">
203
+ <span className="flex flex-wrap items-center gap-1.5">
204
+ <AmountBadge
205
+ tone={displayTone}
206
+ icon={
207
+ displayTone === 'positive' ? (
208
+ <TrendingUp className="h-3.5 w-3.5" />
209
+ ) : displayTone === 'negative' ? (
210
+ <TrendingDown className="h-3.5 w-3.5" />
211
+ ) : displayTone === 'varied' ? (
212
+ <TriangleAlert className="h-3.5 w-3.5" />
213
+ ) : undefined
214
+ }
215
+ >
216
+ {displayPrimary}
217
+ </AmountBadge>
218
+ {displayConverted && (
219
+ <span className="rounded-md border bg-muted/40 px-2 py-0.5 text-muted-foreground text-xs">
220
+ {'\u2248'} {displayConverted}
221
+ </span>
222
+ )}
223
+ </span>
224
+ {showAuditContext && typeof contextBalance === 'number' && (
225
+ <span className="flex flex-wrap items-center gap-1.5">
226
+ <ContextAmountBadge
227
+ tone="ledger"
228
+ icon={<BookOpen className="h-3 w-3" />}
229
+ label={isAuditedMode ? t('ledger') : t('audited')}
230
+ >
231
+ {formatCurrency(contextBalance, resolvedCurrency, undefined, {
232
+ signDisplay: 'auto',
233
+ })}
234
+ </ContextAmountBadge>
235
+ <ContextAmountBadge
236
+ tone="variance"
237
+ icon={<Scale className="h-3 w-3" />}
238
+ label={t('variance')}
239
+ >
240
+ {formatCurrency(
241
+ resolvedAuditVariance ?? 0,
242
+ resolvedCurrency,
243
+ undefined,
244
+ { signDisplay: 'always' }
245
+ )}
246
+ </ContextAmountBadge>
247
+ </span>
248
+ )}
249
+ {isAuditedMode && resolvedAuditStatus === 'no_checkpoint' && (
250
+ <span className="text-muted-foreground text-xs">
251
+ {t('no_checkpoint_short')}
32
252
  </span>
33
253
  )}
34
254
  </span>
@@ -1,3 +1,5 @@
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(() => {
@@ -31,6 +33,8 @@ const mocks = vi.hoisted(() => {
31
33
  notFound: vi.fn(() => {
32
34
  throw new Error('notFound');
33
35
  }),
36
+ creditWalletSummary: vi.fn((_props: unknown) => null),
37
+ walletDetailsActions: vi.fn((_props: unknown) => null),
34
38
  withForwardedInternalApiAuth: vi.fn(() => ({ headers: {} })),
35
39
  };
36
40
  });
@@ -101,7 +105,9 @@ vi.mock('../checkpoints/wallet-checkpoint-panel', () => ({
101
105
  }));
102
106
 
103
107
  vi.mock('./credit-wallet-summary', () => ({
104
- CreditWalletSummary: () => null,
108
+ CreditWalletSummary: (
109
+ ...args: Parameters<typeof mocks.creditWalletSummary>
110
+ ) => mocks.creditWalletSummary(...args),
105
111
  }));
106
112
 
107
113
  vi.mock('./interest', () => ({
@@ -109,7 +115,9 @@ vi.mock('./interest', () => ({
109
115
  }));
110
116
 
111
117
  vi.mock('./wallet-details-actions', () => ({
112
- WalletDetailsActions: () => null,
118
+ WalletDetailsActions: (
119
+ ...args: Parameters<typeof mocks.walletDetailsActions>
120
+ ) => mocks.walletDetailsActions(...args),
113
121
  }));
114
122
 
115
123
  vi.mock('./wallet-role-access-dialog', () => ({
@@ -162,4 +170,58 @@ describe('wallet details page', () => {
162
170
  headers: {},
163
171
  });
164
172
  });
173
+
174
+ it('forwards credit wallet actions to the shared actions component', async () => {
175
+ mocks.getWallet.mockResolvedValue({
176
+ id: 'wallet-1',
177
+ name: 'Rewards Card',
178
+ type: 'CREDIT',
179
+ currency: 'USD',
180
+ balance: -120,
181
+ });
182
+
183
+ const { default: WalletDetailsPageModule } = await import(
184
+ './wallet-details-page.js'
185
+ );
186
+ const WalletDetailsPage = WalletDetailsPageModule as unknown as (props: {
187
+ wsId: string;
188
+ walletId: string;
189
+ searchParams: {
190
+ action?: string;
191
+ q: string;
192
+ page: string;
193
+ pageSize: string;
194
+ };
195
+ }) => Promise<ReactElement>;
196
+
197
+ const element = await WalletDetailsPage({
198
+ wsId: 'ws-1',
199
+ walletId: 'wallet-1',
200
+ searchParams: { action: 'payment', q: '', page: '1', pageSize: '10' },
201
+ });
202
+
203
+ render(element);
204
+
205
+ expect(mocks.walletDetailsActions).toHaveBeenCalled();
206
+ const actionProps = mocks.walletDetailsActions.mock.calls[0]?.[0];
207
+
208
+ expect(actionProps).toEqual(
209
+ expect.objectContaining({
210
+ initialAction: 'payment',
211
+ walletId: 'wallet-1',
212
+ })
213
+ );
214
+ expect(mocks.creditWalletSummary).toHaveBeenCalled();
215
+ const summaryProps = mocks.creditWalletSummary.mock.calls[0]?.[0];
216
+
217
+ expect(summaryProps).toEqual(
218
+ expect.objectContaining({
219
+ wsId: 'ws-1',
220
+ wallet: expect.objectContaining({
221
+ id: 'wallet-1',
222
+ type: 'CREDIT',
223
+ }),
224
+ })
225
+ );
226
+ });
165
227
  });
@@ -12,8 +12,7 @@ import { InfiniteTransactionsList } from '@tuturuuu/ui/finance/transactions/infi
12
12
  import { Separator } from '@tuturuuu/ui/separator';
13
13
  import { Skeleton } from '@tuturuuu/ui/skeleton';
14
14
  import type { ExchangeRate } from '@tuturuuu/utils/exchange-rates';
15
- import { convertCurrency } from '@tuturuuu/utils/exchange-rates';
16
- import { formatCurrency, getCurrencyLocale } from '@tuturuuu/utils/format';
15
+ import { getCurrencyLocale } from '@tuturuuu/utils/format';
17
16
  import {
18
17
  getPermissions,
19
18
  getWorkspace,
@@ -31,7 +30,10 @@ import { WalletCheckpointPanel } from '../checkpoints/wallet-checkpoint-panel';
31
30
  import { WalletIconDisplay } from '../wallet-icon-display';
32
31
  import { CreditWalletSummary } from './credit-wallet-summary';
33
32
  import { WalletInterestSection } from './interest';
34
- import { WalletDetailsActions } from './wallet-details-actions';
33
+ import {
34
+ type WalletDetailsAction,
35
+ WalletDetailsActions,
36
+ } from './wallet-details-actions';
35
37
  import { WalletDetailsAmount } from './wallet-details-amount';
36
38
  import WalletRoleAccessDialog from './wallet-role-access-dialog';
37
39
 
@@ -39,6 +41,7 @@ interface Props {
39
41
  wsId: string;
40
42
  walletId: string;
41
43
  searchParams: {
44
+ action?: string;
42
45
  q: string;
43
46
  page: string;
44
47
  pageSize: string;
@@ -55,6 +58,7 @@ interface Props {
55
58
  export default async function WalletDetailsPage({
56
59
  wsId,
57
60
  walletId,
61
+ searchParams,
58
62
  defaultCurrency,
59
63
  internalApiOptions,
60
64
  permissions,
@@ -114,31 +118,10 @@ export default async function WalletDetailsPage({
114
118
 
115
119
  const currency = wallet.currency || resolvedDefaultCurrency || 'USD';
116
120
  const workspaceCurrency = resolvedDefaultCurrency || 'USD';
121
+ const initialAction = getInitialWalletAction(searchParams.action);
117
122
 
118
123
  // Fetch exchange rates for conversion display
119
124
  const exchangeRates = await getExchangeRates();
120
- let convertedBalanceText: string | null = null;
121
- if (
122
- currency !== workspaceCurrency &&
123
- exchangeRates.length > 0 &&
124
- wallet.balance &&
125
- wallet.balance !== 0
126
- ) {
127
- const converted = convertCurrency(
128
- wallet.balance,
129
- currency,
130
- workspaceCurrency,
131
- exchangeRates
132
- );
133
- if (converted !== null) {
134
- convertedBalanceText = formatCurrency(
135
- Math.abs(converted),
136
- workspaceCurrency,
137
- undefined,
138
- { signDisplay: 'never', maximumFractionDigits: 0 }
139
- );
140
- }
141
- }
142
125
 
143
126
  return (
144
127
  <div className="flex min-h-full w-full flex-col">
@@ -161,6 +144,7 @@ export default async function WalletDetailsPage({
161
144
  wsId={wsId}
162
145
  walletId={walletId}
163
146
  wallet={wallet as Wallet}
147
+ initialAction={initialAction}
164
148
  canUpdateWallets={canUpdateWallets}
165
149
  canCreateTransactions={canCreateTransactions}
166
150
  canCreateConfidentialTransactions={canCreateConfidentialTransactions}
@@ -172,6 +156,12 @@ export default async function WalletDetailsPage({
172
156
  />
173
157
  </div>
174
158
  <Separator className="my-4" />
159
+ {wallet.type === 'CREDIT' && (
160
+ <>
161
+ <CreditWalletSummary wsId={wsId} wallet={wallet as Wallet} />
162
+ <Separator className="my-4" />
163
+ </>
164
+ )}
175
165
  <div className="grid h-fit gap-4 md:grid-cols-2">
176
166
  <Card className="grid gap-4">
177
167
  <div className="grid h-fit gap-2 rounded-lg p-4">
@@ -195,11 +185,17 @@ export default async function WalletDetailsPage({
195
185
  label={t('wallet-data-table.balance')}
196
186
  value={
197
187
  <WalletDetailsAmount
188
+ auditedBalance={wallet.audit_balance}
189
+ auditStatus={wallet.audit_status}
190
+ auditVariance={wallet.audit_variance}
191
+ currency={currency}
192
+ exchangeRates={exchangeRates}
193
+ ledgerBalance={wallet.balance ?? 0}
198
194
  primary={Intl.NumberFormat(getCurrencyLocale(currency), {
199
195
  style: 'currency',
200
196
  currency,
201
197
  }).format(wallet.balance || 0)}
202
- converted={convertedBalanceText}
198
+ workspaceCurrency={workspaceCurrency}
203
199
  />
204
200
  }
205
201
  />
@@ -294,12 +290,6 @@ export default async function WalletDetailsPage({
294
290
  <Separator className="my-4" />
295
291
  </>
296
292
  )}
297
- {wallet.type === 'CREDIT' && (
298
- <>
299
- <CreditWalletSummary wsId={wsId} wallet={wallet as Wallet} />
300
- <Separator className="my-4" />
301
- </>
302
- )}
303
293
  <WalletCheckpointPanel
304
294
  wsId={wsId}
305
295
  walletId={walletId}
@@ -341,6 +331,18 @@ export default async function WalletDetailsPage({
341
331
  );
342
332
  }
343
333
 
334
+ function getInitialWalletAction(action?: string): WalletDetailsAction | null {
335
+ switch (action) {
336
+ case 'charge':
337
+ case 'payment':
338
+ case 'credit':
339
+ case 'edit':
340
+ return action;
341
+ default:
342
+ return null;
343
+ }
344
+ }
345
+
344
346
  function DetailItem({
345
347
  icon,
346
348
  label,
@@ -352,9 +354,14 @@ function DetailItem({
352
354
  }) {
353
355
  if (!value) return undefined;
354
356
  return (
355
- <div className="flex items-center gap-1">
356
- {icon}
357
- <span className="font-semibold">{label}:</span> {value}
357
+ <div className="flex min-w-0 items-start gap-3 rounded-md py-1.5">
358
+ <span className="mt-0.5 flex shrink-0 text-muted-foreground [&_svg]:h-5 [&_svg]:w-5">
359
+ {icon}
360
+ </span>
361
+ <div className="min-w-0 flex-1">
362
+ <div className="text-muted-foreground text-xs">{label}</div>
363
+ <div className="min-w-0 font-medium">{value}</div>
364
+ </div>
358
365
  </div>
359
366
  );
360
367
  }
@@ -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
+ });