@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,362 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import {
5
+ Calculator,
6
+ Loader2,
7
+ TrendingDown,
8
+ TrendingUp,
9
+ Wallet as WalletIcon,
10
+ } from '@tuturuuu/icons';
11
+ import {
12
+ createWalletCheckpointBatch,
13
+ listWallets,
14
+ type WalletCheckpointBatchPayload,
15
+ } from '@tuturuuu/internal-api/finance';
16
+ import { Badge } from '@tuturuuu/ui/badge';
17
+ import { Button } from '@tuturuuu/ui/button';
18
+ import {
19
+ Dialog,
20
+ DialogContent,
21
+ DialogDescription,
22
+ DialogFooter,
23
+ DialogHeader,
24
+ DialogTitle,
25
+ } from '@tuturuuu/ui/dialog';
26
+ import { Input } from '@tuturuuu/ui/input';
27
+ import { Label } from '@tuturuuu/ui/label';
28
+ import { toast } from '@tuturuuu/ui/sonner';
29
+ import { cn } from '@tuturuuu/utils/format';
30
+ import { useTranslations } from 'next-intl';
31
+ import type { ReactNode } from 'react';
32
+ import { useMemo, useState } from 'react';
33
+ import { useFinanceBalanceMode } from '../../shared/use-finance-balance-mode';
34
+ import {
35
+ getWalletBalanceTone,
36
+ resolveWalletBalanceForMode,
37
+ } from '../../shared/wallet-balance-mode';
38
+ import { invalidateWalletMutationQueries } from '../query-invalidation';
39
+ import { WalletCheckpointAmount } from './wallet-checkpoint-amount';
40
+
41
+ type WalletInput = {
42
+ audit_balance?: number | null;
43
+ audit_status?: 'clean' | 'no_checkpoint' | 'unresolved' | null;
44
+ audit_variance?: number | null;
45
+ balance?: number | null;
46
+ currency: string;
47
+ id: string;
48
+ name?: string | null;
49
+ };
50
+
51
+ export function WalletTotalCheckDialog({
52
+ canUpdateWallets,
53
+ currency,
54
+ wsId,
55
+ }: {
56
+ canUpdateWallets: boolean;
57
+ currency: string;
58
+ wsId: string;
59
+ }) {
60
+ const t = useTranslations('wallet-checkpoints');
61
+ const queryClient = useQueryClient();
62
+ const { isAuditedMode } = useFinanceBalanceMode();
63
+ const [open, setOpen] = useState(false);
64
+ const [values, setValues] = useState<Record<string, string>>({});
65
+ const walletsQuery = useQuery({
66
+ enabled: open && canUpdateWallets,
67
+ queryFn: () => listWallets(wsId),
68
+ queryKey: ['wallets', wsId, 'all-wallet-check'],
69
+ });
70
+ const wallets = useMemo<WalletInput[]>(
71
+ () =>
72
+ (walletsQuery.data ?? []).flatMap((wallet) => {
73
+ if (!wallet.id) return [];
74
+
75
+ return [
76
+ {
77
+ audit_balance: wallet.audit_balance,
78
+ audit_status: wallet.audit_status,
79
+ audit_variance: wallet.audit_variance,
80
+ balance: wallet.balance,
81
+ currency: wallet.currency || currency,
82
+ id: wallet.id,
83
+ name: wallet.name,
84
+ },
85
+ ];
86
+ }),
87
+ [currency, walletsQuery.data]
88
+ );
89
+ const mutation = useMutation({
90
+ mutationFn: (payload: WalletCheckpointBatchPayload) =>
91
+ createWalletCheckpointBatch(wsId, payload),
92
+ onSuccess: () => {
93
+ toast.success(t('batch_saved'));
94
+ setValues({});
95
+ setOpen(false);
96
+ invalidateWalletMutationQueries(queryClient, wsId);
97
+ },
98
+ onError: (error) => {
99
+ toast.error(error instanceof Error ? error.message : t('batch_error'));
100
+ },
101
+ });
102
+ const totals = useMemo(() => {
103
+ const grouped = new Map<string, number>();
104
+ for (const wallet of wallets) {
105
+ const rawValue = values[wallet.id];
106
+ const amount = rawValue === undefined ? Number.NaN : Number(rawValue);
107
+ if (!Number.isFinite(amount)) continue;
108
+ grouped.set(
109
+ wallet.currency,
110
+ (grouped.get(wallet.currency) ?? 0) + amount
111
+ );
112
+ }
113
+ return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b));
114
+ }, [values, wallets]);
115
+ const canSubmit =
116
+ !walletsQuery.isLoading &&
117
+ wallets.length > 0 &&
118
+ wallets.every((wallet) => Number.isFinite(Number(values[wallet.id])));
119
+
120
+ if (!canUpdateWallets) {
121
+ return null;
122
+ }
123
+
124
+ return (
125
+ <>
126
+ <Button variant="outline" onClick={() => setOpen(true)}>
127
+ <Calculator className="mr-2 h-4 w-4" />
128
+ {t('all_wallet_check')}
129
+ </Button>
130
+ <Dialog open={open} onOpenChange={setOpen}>
131
+ <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
132
+ <DialogHeader>
133
+ <DialogTitle>{t('all_wallet_check')}</DialogTitle>
134
+ <DialogDescription>
135
+ {t('all_wallet_check_description')}
136
+ </DialogDescription>
137
+ </DialogHeader>
138
+ {walletsQuery.isLoading ? (
139
+ <div className="flex items-center gap-2 rounded-md border border-dashed p-4 text-muted-foreground text-sm">
140
+ <Loader2 className="h-4 w-4 animate-spin" />
141
+ {t('loading_wallets')}
142
+ </div>
143
+ ) : walletsQuery.isError ? (
144
+ <div className="rounded-md border border-dynamic-red/30 bg-dynamic-red/5 p-4 text-dynamic-red text-sm">
145
+ {walletsQuery.error instanceof Error
146
+ ? walletsQuery.error.message
147
+ : t('wallets_load_error')}
148
+ </div>
149
+ ) : wallets.length === 0 ? (
150
+ <div className="rounded-md border border-dashed p-4 text-muted-foreground text-sm">
151
+ {t('no_wallets')}
152
+ </div>
153
+ ) : (
154
+ <div className="grid gap-3">
155
+ {wallets.map((wallet) => (
156
+ <WalletTotalCheckRow
157
+ key={wallet.id}
158
+ isAuditedMode={isAuditedMode}
159
+ onValueChange={(value) =>
160
+ setValues((current) => ({
161
+ ...current,
162
+ [wallet.id]: value,
163
+ }))
164
+ }
165
+ t={t}
166
+ value={values[wallet.id] ?? ''}
167
+ wallet={wallet}
168
+ />
169
+ ))}
170
+ {totals.length > 0 && (
171
+ <div className="rounded-md border p-3">
172
+ <div className="font-medium text-sm">{t('totals')}</div>
173
+ <div className="mt-2 grid gap-1 text-sm">
174
+ {totals.map(([currency, total]) => (
175
+ <div
176
+ key={currency}
177
+ className="flex items-center justify-between gap-3"
178
+ >
179
+ <span>{currency}</span>
180
+ <span>
181
+ <WalletCheckpointAmount
182
+ amount={total}
183
+ currency={currency}
184
+ />
185
+ </span>
186
+ </div>
187
+ ))}
188
+ </div>
189
+ </div>
190
+ )}
191
+ </div>
192
+ )}
193
+ <DialogFooter>
194
+ <Button variant="outline" onClick={() => setOpen(false)}>
195
+ {t('cancel')}
196
+ </Button>
197
+ <Button
198
+ disabled={
199
+ !canSubmit || mutation.isPending || walletsQuery.isError
200
+ }
201
+ onClick={() => {
202
+ mutation.mutate({
203
+ checked_at: new Date().toISOString(),
204
+ entries: wallets.map((wallet) => ({
205
+ actual_balance: Number(values[wallet.id]),
206
+ wallet_id: wallet.id,
207
+ })),
208
+ });
209
+ }}
210
+ >
211
+ {mutation.isPending ? t('saving') : t('save_checkpoints')}
212
+ </Button>
213
+ </DialogFooter>
214
+ </DialogContent>
215
+ </Dialog>
216
+ </>
217
+ );
218
+ }
219
+
220
+ function getAmountBadgeClassName(
221
+ tone: ReturnType<typeof getWalletBalanceTone>
222
+ ) {
223
+ if (tone === 'positive') {
224
+ return 'border-dynamic-green/30 bg-dynamic-green/10 font-semibold text-dynamic-green';
225
+ }
226
+
227
+ if (tone === 'negative') {
228
+ return 'border-dynamic-red/30 bg-dynamic-red/10 font-semibold text-dynamic-red';
229
+ }
230
+
231
+ return 'font-semibold text-muted-foreground';
232
+ }
233
+
234
+ function AmountBadge({
235
+ amount,
236
+ currency,
237
+ icon,
238
+ label,
239
+ signDisplay = 'auto',
240
+ }: {
241
+ amount: number;
242
+ currency: string;
243
+ icon?: ReactNode;
244
+ label: string;
245
+ signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
246
+ }) {
247
+ return (
248
+ <Badge
249
+ variant="outline"
250
+ className={cn(
251
+ 'flex w-fit items-center gap-1 whitespace-nowrap',
252
+ getAmountBadgeClassName(getWalletBalanceTone(amount))
253
+ )}
254
+ >
255
+ {icon}
256
+ <span className="font-medium opacity-75">{label}</span>
257
+ <WalletCheckpointAmount
258
+ amount={amount}
259
+ currency={currency}
260
+ signDisplay={signDisplay}
261
+ />
262
+ </Badge>
263
+ );
264
+ }
265
+
266
+ function WalletTotalCheckRow({
267
+ isAuditedMode,
268
+ onValueChange,
269
+ t,
270
+ value,
271
+ wallet,
272
+ }: {
273
+ isAuditedMode: boolean;
274
+ onValueChange: (value: string) => void;
275
+ t: ReturnType<typeof useTranslations>;
276
+ value: string;
277
+ wallet: WalletInput;
278
+ }) {
279
+ const {
280
+ auditStatus,
281
+ auditVariance,
282
+ contextBalance,
283
+ displayBalance,
284
+ hasAuditedBalance,
285
+ usesAuditedBalance,
286
+ } = resolveWalletBalanceForMode(wallet, isAuditedMode ? 'audited' : 'ledger');
287
+ const displayTone = getWalletBalanceTone(displayBalance);
288
+ const varianceTone = getWalletBalanceTone(auditVariance ?? 0);
289
+ const showAuditContext =
290
+ hasAuditedBalance &&
291
+ auditStatus !== 'clean' &&
292
+ auditStatus !== 'no_checkpoint' &&
293
+ auditVariance !== null &&
294
+ auditVariance !== 0;
295
+
296
+ return (
297
+ <div className="grid gap-2 rounded-md border p-3">
298
+ <div className="flex items-center justify-between gap-3">
299
+ <div className="min-w-0">
300
+ <div className="truncate font-medium text-sm">{wallet.name}</div>
301
+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
302
+ <AmountBadge
303
+ amount={displayBalance}
304
+ currency={wallet.currency}
305
+ icon={
306
+ displayTone === 'positive' ? (
307
+ <TrendingUp className="h-3 w-3" />
308
+ ) : displayTone === 'negative' ? (
309
+ <TrendingDown className="h-3 w-3" />
310
+ ) : undefined
311
+ }
312
+ label={usesAuditedBalance ? t('audited') : t('ledger')}
313
+ />
314
+ {showAuditContext && contextBalance !== null && (
315
+ <>
316
+ <AmountBadge
317
+ amount={contextBalance}
318
+ currency={wallet.currency}
319
+ icon={<WalletIcon className="h-3 w-3" />}
320
+ label={isAuditedMode ? t('ledger') : t('audited')}
321
+ />
322
+ <AmountBadge
323
+ amount={auditVariance ?? 0}
324
+ currency={wallet.currency}
325
+ icon={
326
+ varianceTone === 'negative' ? (
327
+ <TrendingDown className="h-3 w-3" />
328
+ ) : (
329
+ <TrendingUp className="h-3 w-3" />
330
+ )
331
+ }
332
+ label={t('variance')}
333
+ signDisplay="always"
334
+ />
335
+ </>
336
+ )}
337
+ </div>
338
+ {isAuditedMode && auditStatus === 'no_checkpoint' && (
339
+ <div className="text-muted-foreground text-xs">
340
+ {t('no_checkpoint_short')}
341
+ </div>
342
+ )}
343
+ </div>
344
+ <div className="text-muted-foreground text-xs">{wallet.currency}</div>
345
+ </div>
346
+ <div className="grid gap-2">
347
+ <Label htmlFor={`wallet-check-${wallet.id}`}>
348
+ {t('actual_balance_with_currency', {
349
+ currency: wallet.currency,
350
+ })}
351
+ </Label>
352
+ <Input
353
+ id={`wallet-check-${wallet.id}`}
354
+ inputMode="decimal"
355
+ value={value}
356
+ onChange={(event) => onValueChange(event.target.value)}
357
+ placeholder="0"
358
+ />
359
+ </div>
360
+ </div>
361
+ );
362
+ }
@@ -0,0 +1,125 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import type { Wallet } from '@tuturuuu/types/primitives/Wallet';
3
+ import type { ReactElement } from 'react';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { walletColumns } from './columns';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ isConfidential: false,
9
+ }));
10
+
11
+ vi.mock('next-intl', () => ({
12
+ useTranslations: () => (key: string) => key,
13
+ }));
14
+
15
+ vi.mock('../shared/use-finance-confidential-visibility', () => ({
16
+ FINANCE_HIDDEN_AMOUNT: 'hidden amount',
17
+ useFinanceConfidentialVisibility: () => ({
18
+ isConfidential: mocks.isConfidential,
19
+ }),
20
+ }));
21
+
22
+ function renderBalanceCell(
23
+ wallet: Wallet,
24
+ balanceMode: 'audited' | 'ledger' = 'audited'
25
+ ) {
26
+ const columns = walletColumns({
27
+ extraData: {
28
+ balanceMode,
29
+ currency: wallet.currency || 'USD',
30
+ },
31
+ namespace: 'wallet-data-table',
32
+ t: (key: string) => key,
33
+ });
34
+ const balanceColumn = columns.find((column) => column.id === 'balance');
35
+
36
+ if (!balanceColumn || typeof balanceColumn.cell !== 'function') {
37
+ throw new Error('Expected balance column cell renderer');
38
+ }
39
+
40
+ render(
41
+ balanceColumn.cell({
42
+ row: {
43
+ original: wallet,
44
+ },
45
+ } as never) as ReactElement
46
+ );
47
+ }
48
+
49
+ describe('wallet balance badge rendering', () => {
50
+ beforeEach(() => {
51
+ mocks.isConfidential = false;
52
+ });
53
+
54
+ it('suppresses ledger and variance badges for clean checkpoints', () => {
55
+ renderBalanceCell({
56
+ audit_balance: 100,
57
+ audit_status: 'clean',
58
+ audit_variance: 0,
59
+ balance: 100,
60
+ currency: 'USD',
61
+ } as Wallet);
62
+
63
+ expect(screen.queryByText('ledger')).not.toBeInTheDocument();
64
+ expect(screen.queryByText('variance')).not.toBeInTheDocument();
65
+ });
66
+
67
+ it('shows distinct context badges only while hovering the balance', () => {
68
+ renderBalanceCell({
69
+ audit_balance: 95,
70
+ audit_status: 'unresolved',
71
+ audit_variance: -5,
72
+ balance: 100,
73
+ currency: 'USD',
74
+ } as Wallet);
75
+
76
+ expect(screen.queryByText('ledger')).not.toBeInTheDocument();
77
+ expect(screen.queryByText('variance')).not.toBeInTheDocument();
78
+
79
+ const trigger = screen
80
+ .getByText('$95.00')
81
+ .closest('[data-wallet-balance-trigger]');
82
+ const balanceBadge = screen
83
+ .getByText('$95.00')
84
+ .closest('[data-wallet-balance-badge="varied"]');
85
+
86
+ expect(trigger).not.toBeNull();
87
+ expect(balanceBadge).toHaveClass('text-dynamic-orange');
88
+ fireEvent.mouseEnter(trigger as Element);
89
+
90
+ expect(screen.getByText('ledger')).toBeInTheDocument();
91
+ expect(screen.getByText('variance')).toBeInTheDocument();
92
+
93
+ expect(
94
+ screen
95
+ .getByText('ledger')
96
+ .closest('[data-wallet-balance-context-badge="ledger"]')
97
+ ).toHaveClass('text-dynamic-blue');
98
+ expect(
99
+ screen
100
+ .getByText('variance')
101
+ .closest('[data-wallet-balance-context-badge="variance"]')
102
+ ).toHaveClass('text-dynamic-purple');
103
+
104
+ fireEvent.mouseLeave(trigger as Element);
105
+
106
+ expect(screen.queryByText('ledger')).not.toBeInTheDocument();
107
+ expect(screen.queryByText('variance')).not.toBeInTheDocument();
108
+ });
109
+
110
+ it('hides all audit amount context when numbers are hidden', () => {
111
+ mocks.isConfidential = true;
112
+
113
+ renderBalanceCell({
114
+ audit_balance: 95,
115
+ audit_status: 'unresolved',
116
+ audit_variance: -5,
117
+ balance: 100,
118
+ currency: 'USD',
119
+ } as Wallet);
120
+
121
+ expect(screen.getByText('hidden amount')).toBeInTheDocument();
122
+ expect(screen.queryByText('ledger')).not.toBeInTheDocument();
123
+ expect(screen.queryByText('variance')).not.toBeInTheDocument();
124
+ });
125
+ });
@@ -0,0 +1,56 @@
1
+ import type { Wallet } from '@tuturuuu/types/primitives/Wallet';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { walletColumns } from './columns';
4
+
5
+ function getBalanceAccessor(balanceMode: 'audited' | 'ledger') {
6
+ const columns = walletColumns({
7
+ extraData: {
8
+ balanceMode,
9
+ currency: 'USD',
10
+ },
11
+ namespace: 'wallet-data-table',
12
+ t: (key: string) => key,
13
+ });
14
+ const balanceColumn = columns.find((column) => column.id === 'balance');
15
+
16
+ if (!balanceColumn || !('accessorFn' in balanceColumn)) {
17
+ throw new Error('Expected balance column accessor');
18
+ }
19
+
20
+ return balanceColumn.accessorFn as (wallet: Wallet) => number;
21
+ }
22
+
23
+ describe('wallet columns', () => {
24
+ it('sorts the balance column by ledger balance in ledger mode', () => {
25
+ const accessor = getBalanceAccessor('ledger');
26
+
27
+ expect(
28
+ accessor({
29
+ audit_balance: 500,
30
+ balance: 100,
31
+ } as Wallet)
32
+ ).toBe(100);
33
+ });
34
+
35
+ it('sorts the balance column by audited balance in audited mode', () => {
36
+ const accessor = getBalanceAccessor('audited');
37
+
38
+ expect(
39
+ accessor({
40
+ audit_balance: -50,
41
+ balance: 100,
42
+ } as Wallet)
43
+ ).toBe(-50);
44
+ });
45
+
46
+ it('falls back to ledger balance for audited sorting without a checkpoint', () => {
47
+ const accessor = getBalanceAccessor('audited');
48
+
49
+ expect(
50
+ accessor({
51
+ audit_status: 'no_checkpoint',
52
+ balance: 100,
53
+ } as Wallet)
54
+ ).toBe(100);
55
+ });
56
+ });