@tuturuuu/ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -6
  3. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +62 -0
  4. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +8 -2
  5. package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +332 -0
  6. package/src/components/ui/chat/chat-sidebar-groups.test.ts +44 -0
  7. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +2 -0
  8. package/src/components/ui/chat/chat-sidebar-panel.tsx +6 -0
  9. package/src/components/ui/chat/chat-sidebar-sections.ts +199 -0
  10. package/src/components/ui/chat/chat-sidebar.tsx +11 -258
  11. package/src/components/ui/chat/chat-workspace.tsx +5 -0
  12. package/src/components/ui/chat/utils.ts +7 -0
  13. package/src/components/ui/custom/settings/task-settings.tsx +76 -0
  14. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +126 -0
  15. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  16. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  17. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  18. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  19. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +172 -0
  20. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  21. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +196 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +277 -0
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +189 -0
  27. package/src/components/ui/finance/wallets/query-invalidation.ts +2 -0
  28. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +7 -3
  29. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +10 -0
  30. package/src/components/ui/finance/wallets/wallets-page.test.tsx +7 -0
  31. package/src/components/ui/finance/wallets/wallets-page.tsx +21 -5
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  34. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  35. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +10 -0
  36. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +141 -0
  37. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +208 -0
  38. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  39. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  40. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +24 -0
  41. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +18 -3
  42. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  43. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  44. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  45. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +411 -323
  46. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  47. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  48. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  49. package/src/hooks/use-task-actions.ts +45 -0
@@ -0,0 +1,201 @@
1
+ 'use client';
2
+
3
+ import { CheckCircle2, Pencil, Trash2, TriangleAlert } from '@tuturuuu/icons';
4
+ import type {
5
+ WalletCheckpoint,
6
+ WalletCheckpointInterval,
7
+ } from '@tuturuuu/internal-api/finance';
8
+ import { Badge } from '@tuturuuu/ui/badge';
9
+ import { Button } from '@tuturuuu/ui/button';
10
+ import { useTranslations } from 'next-intl';
11
+ import { WalletCheckpointAmount } from './wallet-checkpoint-amount';
12
+
13
+ export function LatestCheckpoint({
14
+ checkpoint,
15
+ currency,
16
+ formatDate,
17
+ }: {
18
+ checkpoint: WalletCheckpoint;
19
+ currency: string;
20
+ formatDate: (value: string) => string;
21
+ }) {
22
+ const t = useTranslations('wallet-checkpoints');
23
+ return (
24
+ <div className="grid gap-2 rounded-md border p-3 text-sm md:grid-cols-3">
25
+ <Metric label={t('latest_checkpoint')}>
26
+ {formatDate(checkpoint.checked_at)}
27
+ </Metric>
28
+ <Metric label={t('actual_balance')}>
29
+ <WalletCheckpointAmount
30
+ amount={checkpoint.actual_balance}
31
+ currency={currency}
32
+ />
33
+ </Metric>
34
+ <Metric label={t('current_variance')}>
35
+ <WalletCheckpointAmount
36
+ amount={checkpoint.current_variance}
37
+ currency={currency}
38
+ signDisplay="always"
39
+ />
40
+ </Metric>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export function WalletCheckpointIntervals({
46
+ canCreateTransactions,
47
+ currency,
48
+ formatDate,
49
+ intervals,
50
+ onAdjust,
51
+ }: {
52
+ canCreateTransactions: boolean;
53
+ currency: string;
54
+ formatDate: (value: string) => string;
55
+ intervals: WalletCheckpointInterval[];
56
+ onAdjust: (interval: WalletCheckpointInterval) => void;
57
+ }) {
58
+ const t = useTranslations('wallet-checkpoints');
59
+ if (intervals.length === 0) return null;
60
+
61
+ return (
62
+ <div className="space-y-2">
63
+ <div className="font-medium text-sm">{t('intervals')}</div>
64
+ {intervals.map((interval) => (
65
+ <div
66
+ key={interval.end_checkpoint_id}
67
+ className={
68
+ interval.is_clean
69
+ ? 'rounded-md border border-dynamic-green/40 p-3'
70
+ : 'rounded-md border border-dynamic-yellow/50 p-3'
71
+ }
72
+ >
73
+ <div className="flex flex-wrap items-center justify-between gap-2">
74
+ <div className="flex items-center gap-2 text-sm">
75
+ {interval.is_clean ? (
76
+ <CheckCircle2 className="h-4 w-4 text-dynamic-green" />
77
+ ) : (
78
+ <TriangleAlert className="h-4 w-4 text-dynamic-yellow" />
79
+ )}
80
+ <span>
81
+ {formatDate(interval.start_checked_at)} -{' '}
82
+ {formatDate(interval.end_checked_at)}
83
+ </span>
84
+ </div>
85
+ <Badge variant={interval.is_clean ? 'secondary' : 'outline'}>
86
+ {interval.is_clean ? t('clean') : t('unresolved')}
87
+ </Badge>
88
+ </div>
89
+ <div className="mt-3 grid gap-2 text-sm md:grid-cols-3">
90
+ <Metric label={t('actual_delta')}>
91
+ <WalletCheckpointAmount
92
+ amount={interval.actual_delta}
93
+ currency={currency}
94
+ />
95
+ </Metric>
96
+ <Metric label={t('ledger_delta')}>
97
+ <WalletCheckpointAmount
98
+ amount={interval.ledger_delta}
99
+ currency={currency}
100
+ />
101
+ </Metric>
102
+ <Metric label={t('variance')}>
103
+ <WalletCheckpointAmount
104
+ amount={interval.interval_variance}
105
+ currency={currency}
106
+ signDisplay="always"
107
+ />
108
+ </Metric>
109
+ </div>
110
+ {!interval.is_clean && canCreateTransactions && (
111
+ <Button
112
+ className="mt-3"
113
+ size="sm"
114
+ variant="outline"
115
+ onClick={() => onAdjust(interval)}
116
+ >
117
+ {t('create_adjustment')}
118
+ </Button>
119
+ )}
120
+ </div>
121
+ ))}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ export function WalletCheckpointTimeline({
127
+ canUpdateWallets,
128
+ checkpoints,
129
+ formatDate,
130
+ onDelete,
131
+ onEdit,
132
+ }: {
133
+ canUpdateWallets: boolean;
134
+ checkpoints: WalletCheckpoint[];
135
+ formatDate: (value: string) => string;
136
+ onDelete: (checkpoint: WalletCheckpoint) => void;
137
+ onEdit: (checkpoint: WalletCheckpoint) => void;
138
+ }) {
139
+ const t = useTranslations('wallet-checkpoints');
140
+ if (checkpoints.length === 0) return null;
141
+
142
+ return (
143
+ <div className="space-y-2">
144
+ <div className="font-medium text-sm">{t('timeline')}</div>
145
+ {checkpoints.map((checkpoint) => (
146
+ <div
147
+ key={checkpoint.id}
148
+ className="flex flex-col gap-2 rounded-md border p-3 md:flex-row md:items-center md:justify-between"
149
+ >
150
+ <div>
151
+ <div className="font-medium text-sm">
152
+ {formatDate(checkpoint.checked_at)}
153
+ </div>
154
+ <div className="text-muted-foreground text-sm">
155
+ <WalletCheckpointAmount
156
+ amount={checkpoint.actual_balance}
157
+ currency={checkpoint.currency}
158
+ />{' '}
159
+ {checkpoint.note ? `- ${checkpoint.note}` : null}
160
+ </div>
161
+ </div>
162
+ {canUpdateWallets && (
163
+ <div className="flex items-center gap-2">
164
+ <Button
165
+ size="icon"
166
+ variant="ghost"
167
+ onClick={() => onEdit(checkpoint)}
168
+ aria-label={t('edit_checkpoint')}
169
+ >
170
+ <Pencil className="h-4 w-4" />
171
+ </Button>
172
+ <Button
173
+ size="icon"
174
+ variant="ghost"
175
+ onClick={() => onDelete(checkpoint)}
176
+ aria-label={t('delete_checkpoint')}
177
+ >
178
+ <Trash2 className="h-4 w-4" />
179
+ </Button>
180
+ </div>
181
+ )}
182
+ </div>
183
+ ))}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ function Metric({
189
+ children,
190
+ label,
191
+ }: {
192
+ children: React.ReactNode;
193
+ label: string;
194
+ }) {
195
+ return (
196
+ <div>
197
+ <div className="text-muted-foreground text-xs">{label}</div>
198
+ <div className="font-medium">{children}</div>
199
+ </div>
200
+ );
201
+ }
@@ -0,0 +1,277 @@
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 { WalletCheckpointAdjustmentDialog } from './wallet-checkpoint-adjustment-dialog';
6
+ import { WalletCheckpointAmount } from './wallet-checkpoint-amount';
7
+ import { WalletCheckpointPanel } from './wallet-checkpoint-panel';
8
+ import { WalletTotalCheckDialog } from './wallet-total-check-dialog';
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ createTransaction: vi.fn(),
12
+ createWalletCheckpointBatch: vi.fn(),
13
+ deleteWalletCheckpoint: vi.fn(),
14
+ isConfidential: true,
15
+ listTransactionCategories: vi.fn(),
16
+ listWalletCheckpoints: vi.fn(),
17
+ success: vi.fn(),
18
+ }));
19
+
20
+ vi.mock('@tuturuuu/internal-api/finance', () => ({
21
+ createTransaction: (...args: Parameters<typeof mocks.createTransaction>) =>
22
+ mocks.createTransaction(...args),
23
+ createWalletCheckpoint: vi.fn(),
24
+ createWalletCheckpointBatch: (
25
+ ...args: Parameters<typeof mocks.createWalletCheckpointBatch>
26
+ ) => mocks.createWalletCheckpointBatch(...args),
27
+ deleteWalletCheckpoint: (
28
+ ...args: Parameters<typeof mocks.deleteWalletCheckpoint>
29
+ ) => mocks.deleteWalletCheckpoint(...args),
30
+ listTransactionCategories: (
31
+ ...args: Parameters<typeof mocks.listTransactionCategories>
32
+ ) => mocks.listTransactionCategories(...args),
33
+ listWalletCheckpoints: (
34
+ ...args: Parameters<typeof mocks.listWalletCheckpoints>
35
+ ) => mocks.listWalletCheckpoints(...args),
36
+ updateWalletCheckpoint: vi.fn(),
37
+ }));
38
+
39
+ vi.mock('@tuturuuu/ui/sonner', () => ({
40
+ toast: {
41
+ error: vi.fn(),
42
+ success: (...args: Parameters<typeof mocks.success>) =>
43
+ mocks.success(...args),
44
+ },
45
+ }));
46
+
47
+ vi.mock('next-intl', () => ({
48
+ useLocale: () => 'en-US',
49
+ useTranslations:
50
+ () => (key: string, values?: Record<string, string | number>) =>
51
+ values ? `${key}:${Object.values(values).join(',')}` : key,
52
+ }));
53
+
54
+ vi.mock('../../shared/use-finance-confidential-visibility', () => ({
55
+ FINANCE_HIDDEN_AMOUNT: '•••••',
56
+ useFinanceConfidentialVisibility: () => ({
57
+ isConfidential: mocks.isConfidential,
58
+ }),
59
+ }));
60
+
61
+ function renderWithQueryClient(ui: ReactElement) {
62
+ const queryClient = new QueryClient({
63
+ defaultOptions: {
64
+ queries: {
65
+ retry: false,
66
+ },
67
+ },
68
+ });
69
+ render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
70
+ }
71
+
72
+ describe('wallet checkpoint UI', () => {
73
+ beforeEach(() => {
74
+ vi.clearAllMocks();
75
+ mocks.isConfidential = true;
76
+ mocks.createTransaction.mockResolvedValue({ transaction_id: 'tx-1' });
77
+ mocks.createWalletCheckpointBatch.mockResolvedValue({
78
+ data: [],
79
+ totals_by_currency: [],
80
+ });
81
+ mocks.listTransactionCategories.mockResolvedValue([]);
82
+ mocks.listWalletCheckpoints.mockResolvedValue({
83
+ data: [],
84
+ intervals: [],
85
+ latest: null,
86
+ });
87
+ });
88
+
89
+ it('masks checkpoint amounts in confidential mode', () => {
90
+ render(<WalletCheckpointAmount amount={123.45} currency="USD" />);
91
+
92
+ expect(screen.getByText('•••••')).toBeInTheDocument();
93
+ });
94
+
95
+ it('shows checkpoint amounts when confidential mode is disabled', () => {
96
+ mocks.isConfidential = false;
97
+
98
+ render(<WalletCheckpointAmount amount={123.45} currency="USD" />);
99
+
100
+ expect(screen.getByText('$123.45')).toBeInTheDocument();
101
+ });
102
+
103
+ it('saves all-wallet checks with typed decimal values intact', async () => {
104
+ renderWithQueryClient(
105
+ <WalletTotalCheckDialog
106
+ wsId="ws-1"
107
+ canUpdateWallets
108
+ wallets={[
109
+ {
110
+ balance: 0,
111
+ currency: 'USD',
112
+ id: 'wallet-1',
113
+ name: 'Cash',
114
+ },
115
+ {
116
+ balance: 0,
117
+ currency: 'VND',
118
+ id: 'wallet-2',
119
+ name: 'Bank',
120
+ },
121
+ ]}
122
+ />
123
+ );
124
+
125
+ fireEvent.click(screen.getByRole('button', { name: 'all_wallet_check' }));
126
+ expect(
127
+ screen.getByRole('button', { name: 'save_checkpoints' })
128
+ ).toBeDisabled();
129
+
130
+ fireEvent.change(
131
+ screen.getByLabelText('actual_balance_with_currency:USD'),
132
+ {
133
+ target: { value: '12.3405' },
134
+ }
135
+ );
136
+ fireEvent.change(
137
+ screen.getByLabelText('actual_balance_with_currency:VND'),
138
+ {
139
+ target: { value: '-5000' },
140
+ }
141
+ );
142
+ fireEvent.click(screen.getByRole('button', { name: 'save_checkpoints' }));
143
+
144
+ await waitFor(() => {
145
+ expect(mocks.createWalletCheckpointBatch).toHaveBeenCalledWith('ws-1', {
146
+ checked_at: expect.any(String),
147
+ entries: [
148
+ {
149
+ actual_balance: 12.3405,
150
+ wallet_id: 'wallet-1',
151
+ },
152
+ {
153
+ actual_balance: -5000,
154
+ wallet_id: 'wallet-2',
155
+ },
156
+ ],
157
+ });
158
+ });
159
+ });
160
+
161
+ it('renders clean and unresolved checkpoint intervals', async () => {
162
+ mocks.listWalletCheckpoints.mockResolvedValue({
163
+ data: [
164
+ {
165
+ actual_balance: 120,
166
+ checked_at: '2026-06-11T10:00:00.000Z',
167
+ created_at: '2026-06-11T10:01:00.000Z',
168
+ created_by: 'user-1',
169
+ currency: 'USD',
170
+ current_ledger_balance: 115,
171
+ current_variance: 5,
172
+ id: 'checkpoint-2',
173
+ ledger_balance: 110,
174
+ note: null,
175
+ original_variance: 10,
176
+ updated_at: '2026-06-11T10:01:00.000Z',
177
+ wallet_id: 'wallet-1',
178
+ },
179
+ ],
180
+ intervals: [
181
+ {
182
+ actual_delta: 10,
183
+ end_actual_balance: 110,
184
+ end_checked_at: '2026-06-10T10:00:00.000Z',
185
+ end_checkpoint_id: 'checkpoint-1',
186
+ interval_variance: 0,
187
+ is_clean: true,
188
+ ledger_delta: 10,
189
+ start_actual_balance: 100,
190
+ start_checked_at: '2026-06-09T10:00:00.000Z',
191
+ start_checkpoint_id: 'checkpoint-0',
192
+ transaction_count: 1,
193
+ },
194
+ {
195
+ actual_delta: 10,
196
+ end_actual_balance: 120,
197
+ end_checked_at: '2026-06-11T10:00:00.000Z',
198
+ end_checkpoint_id: 'checkpoint-2',
199
+ interval_variance: 5,
200
+ is_clean: false,
201
+ ledger_delta: 5,
202
+ start_actual_balance: 110,
203
+ start_checked_at: '2026-06-10T10:00:00.000Z',
204
+ start_checkpoint_id: 'checkpoint-1',
205
+ transaction_count: 2,
206
+ },
207
+ ],
208
+ latest: {
209
+ actual_balance: 120,
210
+ checked_at: '2026-06-11T10:00:00.000Z',
211
+ created_at: '2026-06-11T10:01:00.000Z',
212
+ created_by: 'user-1',
213
+ currency: 'USD',
214
+ current_ledger_balance: 115,
215
+ current_variance: 5,
216
+ id: 'checkpoint-2',
217
+ ledger_balance: 110,
218
+ note: null,
219
+ original_variance: 10,
220
+ updated_at: '2026-06-11T10:01:00.000Z',
221
+ wallet_id: 'wallet-1',
222
+ },
223
+ });
224
+
225
+ renderWithQueryClient(
226
+ <WalletCheckpointPanel
227
+ wsId="ws-1"
228
+ walletId="wallet-1"
229
+ walletName="Cash"
230
+ currency="USD"
231
+ canCreateTransactions
232
+ canUpdateWallets
233
+ />
234
+ );
235
+
236
+ expect(await screen.findByText('clean')).toBeInTheDocument();
237
+ expect(screen.getByText('unresolved')).toBeInTheDocument();
238
+ expect(
239
+ screen.getByRole('button', { name: 'create_adjustment' })
240
+ ).toBeInTheDocument();
241
+ expect(
242
+ screen.getByRole('button', { name: 'edit_checkpoint' })
243
+ ).toBeInTheDocument();
244
+ expect(
245
+ screen.getByRole('button', { name: 'delete_checkpoint' })
246
+ ).toBeInTheDocument();
247
+ });
248
+
249
+ it('creates adjustment transactions with exact signed variance and reports disabled', async () => {
250
+ renderWithQueryClient(
251
+ <WalletCheckpointAdjustmentDialog
252
+ wsId="ws-1"
253
+ walletId="wallet-1"
254
+ walletName="Cash"
255
+ checkedAt="2026-06-11T10:00:00.000Z"
256
+ currency="USD"
257
+ variance={-12.34}
258
+ open
259
+ onOpenChange={vi.fn()}
260
+ onCreated={vi.fn()}
261
+ />
262
+ );
263
+
264
+ fireEvent.click(screen.getByRole('button', { name: 'create_adjustment' }));
265
+
266
+ await waitFor(() => {
267
+ expect(mocks.createTransaction).toHaveBeenCalledWith('ws-1', {
268
+ amount: -12.34,
269
+ category_id: undefined,
270
+ description: expect.stringContaining('Cash'),
271
+ origin_wallet_id: 'wallet-1',
272
+ report_opt_in: false,
273
+ taken_at: expect.any(String),
274
+ });
275
+ });
276
+ });
277
+ });
@@ -0,0 +1,189 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import { Calculator } from '@tuturuuu/icons';
5
+ import {
6
+ createWalletCheckpointBatch,
7
+ type WalletCheckpointBatchPayload,
8
+ } from '@tuturuuu/internal-api/finance';
9
+ import { Button } from '@tuturuuu/ui/button';
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ } from '@tuturuuu/ui/dialog';
18
+ import { Input } from '@tuturuuu/ui/input';
19
+ import { Label } from '@tuturuuu/ui/label';
20
+ import { toast } from '@tuturuuu/ui/sonner';
21
+ import { useTranslations } from 'next-intl';
22
+ import { useMemo, useState } from 'react';
23
+ import { invalidateWalletMutationQueries } from '../query-invalidation';
24
+ import { WalletCheckpointAmount } from './wallet-checkpoint-amount';
25
+
26
+ type WalletInput = {
27
+ balance?: number | null;
28
+ currency: string;
29
+ id: string;
30
+ name?: string | null;
31
+ };
32
+
33
+ export function WalletTotalCheckDialog({
34
+ canUpdateWallets,
35
+ wallets,
36
+ wsId,
37
+ }: {
38
+ canUpdateWallets: boolean;
39
+ wallets: WalletInput[];
40
+ wsId: string;
41
+ }) {
42
+ const t = useTranslations('wallet-checkpoints');
43
+ const queryClient = useQueryClient();
44
+ const [open, setOpen] = useState(false);
45
+ const [values, setValues] = useState<Record<string, string>>({});
46
+ const mutation = useMutation({
47
+ mutationFn: (payload: WalletCheckpointBatchPayload) =>
48
+ createWalletCheckpointBatch(wsId, payload),
49
+ onSuccess: () => {
50
+ toast.success(t('batch_saved'));
51
+ setValues({});
52
+ setOpen(false);
53
+ invalidateWalletMutationQueries(queryClient, wsId);
54
+ },
55
+ onError: (error) => {
56
+ toast.error(error instanceof Error ? error.message : t('batch_error'));
57
+ },
58
+ });
59
+ const totals = useMemo(() => {
60
+ const grouped = new Map<string, number>();
61
+ for (const wallet of wallets) {
62
+ const rawValue = values[wallet.id];
63
+ const amount = rawValue === undefined ? Number.NaN : Number(rawValue);
64
+ if (!Number.isFinite(amount)) continue;
65
+ grouped.set(
66
+ wallet.currency,
67
+ (grouped.get(wallet.currency) ?? 0) + amount
68
+ );
69
+ }
70
+ return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b));
71
+ }, [values, wallets]);
72
+ const canSubmit =
73
+ wallets.length > 0 &&
74
+ wallets.every((wallet) => Number.isFinite(Number(values[wallet.id])));
75
+
76
+ if (!canUpdateWallets) {
77
+ return null;
78
+ }
79
+
80
+ return (
81
+ <>
82
+ <Button variant="outline" onClick={() => setOpen(true)}>
83
+ <Calculator className="mr-2 h-4 w-4" />
84
+ {t('all_wallet_check')}
85
+ </Button>
86
+ <Dialog open={open} onOpenChange={setOpen}>
87
+ <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
88
+ <DialogHeader>
89
+ <DialogTitle>{t('all_wallet_check')}</DialogTitle>
90
+ <DialogDescription>
91
+ {t('all_wallet_check_description')}
92
+ </DialogDescription>
93
+ </DialogHeader>
94
+ {wallets.length === 0 ? (
95
+ <div className="rounded-md border border-dashed p-4 text-muted-foreground text-sm">
96
+ {t('no_wallets')}
97
+ </div>
98
+ ) : (
99
+ <div className="grid gap-3">
100
+ {wallets.map((wallet) => (
101
+ <div
102
+ key={wallet.id}
103
+ className="grid gap-2 rounded-md border p-3"
104
+ >
105
+ <div className="flex items-center justify-between gap-3">
106
+ <div className="min-w-0">
107
+ <div className="truncate font-medium text-sm">
108
+ {wallet.name}
109
+ </div>
110
+ <div className="text-muted-foreground text-xs">
111
+ {t('ledger_balance')}:{' '}
112
+ <WalletCheckpointAmount
113
+ amount={wallet.balance ?? 0}
114
+ currency={wallet.currency}
115
+ />
116
+ </div>
117
+ </div>
118
+ <div className="text-muted-foreground text-xs">
119
+ {wallet.currency}
120
+ </div>
121
+ </div>
122
+ <div className="grid gap-2">
123
+ <Label htmlFor={`wallet-check-${wallet.id}`}>
124
+ {t('actual_balance_with_currency', {
125
+ currency: wallet.currency,
126
+ })}
127
+ </Label>
128
+ <Input
129
+ id={`wallet-check-${wallet.id}`}
130
+ inputMode="decimal"
131
+ value={values[wallet.id] ?? ''}
132
+ onChange={(event) =>
133
+ setValues((current) => ({
134
+ ...current,
135
+ [wallet.id]: event.target.value,
136
+ }))
137
+ }
138
+ placeholder="0"
139
+ />
140
+ </div>
141
+ </div>
142
+ ))}
143
+ {totals.length > 0 && (
144
+ <div className="rounded-md border p-3">
145
+ <div className="font-medium text-sm">{t('totals')}</div>
146
+ <div className="mt-2 grid gap-1 text-sm">
147
+ {totals.map(([currency, total]) => (
148
+ <div
149
+ key={currency}
150
+ className="flex items-center justify-between gap-3"
151
+ >
152
+ <span>{currency}</span>
153
+ <span>
154
+ <WalletCheckpointAmount
155
+ amount={total}
156
+ currency={currency}
157
+ />
158
+ </span>
159
+ </div>
160
+ ))}
161
+ </div>
162
+ </div>
163
+ )}
164
+ </div>
165
+ )}
166
+ <DialogFooter>
167
+ <Button variant="outline" onClick={() => setOpen(false)}>
168
+ {t('cancel')}
169
+ </Button>
170
+ <Button
171
+ disabled={!canSubmit || mutation.isPending}
172
+ onClick={() => {
173
+ mutation.mutate({
174
+ checked_at: new Date().toISOString(),
175
+ entries: wallets.map((wallet) => ({
176
+ actual_balance: Number(values[wallet.id]),
177
+ wallet_id: wallet.id,
178
+ })),
179
+ });
180
+ }}
181
+ >
182
+ {mutation.isPending ? t('saving') : t('save_checkpoints')}
183
+ </Button>
184
+ </DialogFooter>
185
+ </DialogContent>
186
+ </Dialog>
187
+ </>
188
+ );
189
+ }
@@ -14,6 +14,8 @@ const WALLET_MUTATION_QUERY_ROOTS = new Set([
14
14
  'opening-balance',
15
15
  'recurring_transactions',
16
16
  'spending_trends',
17
+ 'wallet-checkpoint-summary',
18
+ 'wallet-checkpoints',
17
19
  'upcoming_recurring_transactions',
18
20
  'wallets',
19
21
  'workspace-invoices',