@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,617 @@
1
+ 'use client';
2
+
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { ExternalLink, History, Search } from '@tuturuuu/icons';
5
+ import {
6
+ getWalletCheckpointHistory,
7
+ type WalletCheckpoint,
8
+ type WalletCheckpointAuditStatus,
9
+ type WalletCheckpointHistoryInterval,
10
+ } from '@tuturuuu/internal-api/finance';
11
+ import { Badge } from '@tuturuuu/ui/badge';
12
+ import { Button } from '@tuturuuu/ui/button';
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from '@tuturuuu/ui/dialog';
20
+ import { Input } from '@tuturuuu/ui/input';
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from '@tuturuuu/ui/select';
28
+ import { Skeleton } from '@tuturuuu/ui/skeleton';
29
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
30
+ import { cn } from '@tuturuuu/utils/format';
31
+ import Link from 'next/link';
32
+ import { useLocale, useTranslations } from 'next-intl';
33
+ import { useMemo, useState } from 'react';
34
+ import { invalidateWalletMutationQueries } from '../query-invalidation';
35
+ import { WalletCheckpointAdjustmentDialog } from './wallet-checkpoint-adjustment-dialog';
36
+ import { WalletCheckpointAmount } from './wallet-checkpoint-amount';
37
+
38
+ const ALL = 'all';
39
+
40
+ type CheckpointStatus = 'clean' | 'no_checkpoint' | 'unresolved';
41
+
42
+ type ReconcileTarget = {
43
+ checkedAt: string;
44
+ checkpointId: string;
45
+ currency: string;
46
+ variance: number;
47
+ walletId: string;
48
+ walletName: string;
49
+ };
50
+
51
+ type WindowRow =
52
+ | {
53
+ interval: WalletCheckpointHistoryInterval;
54
+ status: Exclude<CheckpointStatus, 'no_checkpoint'>;
55
+ type: 'interval';
56
+ }
57
+ | {
58
+ auditStatus: WalletCheckpointAuditStatus;
59
+ currency: string;
60
+ type: 'no_checkpoint';
61
+ walletName: string | null;
62
+ };
63
+
64
+ export function WalletCheckpointHistoryDialog({
65
+ canCreateTransactions,
66
+ financePrefix = '/finance',
67
+ wsId,
68
+ }: {
69
+ canCreateTransactions: boolean;
70
+ financePrefix?: string;
71
+ wsId: string;
72
+ }) {
73
+ const t = useTranslations('wallet-checkpoints');
74
+ const locale = useLocale();
75
+ const queryClient = useQueryClient();
76
+ const [open, setOpen] = useState(false);
77
+ const [search, setSearch] = useState('');
78
+ const [currency, setCurrency] = useState(ALL);
79
+ const [status, setStatus] = useState<typeof ALL | CheckpointStatus>(ALL);
80
+ const [reconcileTarget, setReconcileTarget] =
81
+ useState<ReconcileTarget | null>(null);
82
+ const query = useQuery({
83
+ queryKey: ['wallet-checkpoint-history', wsId],
84
+ queryFn: () => getWalletCheckpointHistory(wsId, { limit: 100 }),
85
+ enabled: open,
86
+ });
87
+ const formatDate = (value: string) =>
88
+ new Intl.DateTimeFormat(locale, {
89
+ dateStyle: 'medium',
90
+ timeStyle: 'short',
91
+ }).format(new Date(value));
92
+ const wallets = query.data?.wallets ?? [];
93
+ const walletById = useMemo(
94
+ () => new Map(wallets.map((wallet) => [wallet.id, wallet])),
95
+ [wallets]
96
+ );
97
+ const auditStatusByWalletId = useMemo(
98
+ () =>
99
+ new Map(
100
+ (query.data?.audit_statuses ?? []).map((auditStatus) => [
101
+ auditStatus.wallet_id,
102
+ auditStatus,
103
+ ])
104
+ ),
105
+ [query.data?.audit_statuses]
106
+ );
107
+ const currencies = useMemo(() => {
108
+ const values = new Set<string>();
109
+ for (const wallet of wallets) values.add(wallet.currency);
110
+ for (const interval of query.data?.intervals ?? []) {
111
+ values.add(interval.currency);
112
+ }
113
+ for (const checkpoint of query.data?.checkpoints ?? []) {
114
+ values.add(checkpoint.currency);
115
+ }
116
+ return [...values].sort((a, b) => a.localeCompare(b));
117
+ }, [query.data?.checkpoints, query.data?.intervals, wallets]);
118
+ const normalizedSearch = search.trim().toLowerCase();
119
+ const windowRows = useMemo<WindowRow[]>(() => {
120
+ const intervals = (query.data?.intervals ?? []).map((interval) => {
121
+ const status: Exclude<CheckpointStatus, 'no_checkpoint'> =
122
+ interval.is_clean ? 'clean' : 'unresolved';
123
+
124
+ return {
125
+ interval,
126
+ status,
127
+ type: 'interval' as const,
128
+ };
129
+ });
130
+ const noCheckpointRows = (query.data?.audit_statuses ?? [])
131
+ .filter((auditStatus) => auditStatus.status === 'no_checkpoint')
132
+ .map((auditStatus) => {
133
+ const wallet = walletById.get(auditStatus.wallet_id);
134
+ return {
135
+ auditStatus,
136
+ currency: wallet?.currency ?? 'USD',
137
+ type: 'no_checkpoint' as const,
138
+ walletName: wallet?.name ?? null,
139
+ };
140
+ });
141
+
142
+ return [...intervals, ...noCheckpointRows];
143
+ }, [query.data?.audit_statuses, query.data?.intervals, walletById]);
144
+ const filteredWindowRows = windowRows.filter((row) => {
145
+ const rowCurrency =
146
+ row.type === 'interval' ? row.interval.currency : row.currency;
147
+ const rowStatus = row.type === 'interval' ? row.status : 'no_checkpoint';
148
+ const walletName =
149
+ row.type === 'interval' ? row.interval.wallet_name : row.walletName;
150
+
151
+ return (
152
+ (currency === ALL || rowCurrency === currency) &&
153
+ (status === ALL || rowStatus === status) &&
154
+ (!normalizedSearch ||
155
+ (walletName ?? '').toLowerCase().includes(normalizedSearch))
156
+ );
157
+ });
158
+ const filteredCheckpoints = (query.data?.checkpoints ?? []).filter(
159
+ (checkpoint) => {
160
+ const wallet = walletById.get(checkpoint.wallet_id);
161
+ const checkpointStatus = getCheckpointStatus(
162
+ checkpoint,
163
+ auditStatusByWalletId.get(checkpoint.wallet_id)
164
+ );
165
+
166
+ return (
167
+ (currency === ALL || checkpoint.currency === currency) &&
168
+ (status === ALL || checkpointStatus === status) &&
169
+ (!normalizedSearch ||
170
+ (wallet?.name ?? '').toLowerCase().includes(normalizedSearch))
171
+ );
172
+ }
173
+ );
174
+
175
+ const refresh = () => {
176
+ queryClient.invalidateQueries({
177
+ queryKey: ['wallet-checkpoint-history', wsId],
178
+ });
179
+ invalidateWalletMutationQueries(queryClient, wsId);
180
+ };
181
+
182
+ return (
183
+ <>
184
+ <Button variant="outline" onClick={() => setOpen(true)}>
185
+ <History className="mr-2 h-4 w-4" />
186
+ {t('checkpoint_history')}
187
+ </Button>
188
+ <Dialog open={open} onOpenChange={setOpen}>
189
+ <DialogContent className="flex max-h-[92vh] w-[min(96vw,1200px)] max-w-none flex-col overflow-hidden">
190
+ <DialogHeader>
191
+ <DialogTitle>{t('checkpoint_history')}</DialogTitle>
192
+ <DialogDescription>
193
+ {t('checkpoint_history_description')}
194
+ </DialogDescription>
195
+ </DialogHeader>
196
+ <div className="grid gap-3 md:grid-cols-[1fr_180px_180px]">
197
+ <div className="relative">
198
+ <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
199
+ <Input
200
+ className="pl-9"
201
+ value={search}
202
+ onChange={(event) => setSearch(event.target.value)}
203
+ placeholder={t('search_wallets')}
204
+ />
205
+ </div>
206
+ <Select value={currency} onValueChange={setCurrency}>
207
+ <SelectTrigger>
208
+ <SelectValue />
209
+ </SelectTrigger>
210
+ <SelectContent>
211
+ <SelectItem value={ALL}>{t('all_currencies')}</SelectItem>
212
+ {currencies.map((value) => (
213
+ <SelectItem key={value} value={value}>
214
+ {value}
215
+ </SelectItem>
216
+ ))}
217
+ </SelectContent>
218
+ </Select>
219
+ <Select
220
+ value={status}
221
+ onValueChange={(value) =>
222
+ setStatus(value as typeof ALL | CheckpointStatus)
223
+ }
224
+ >
225
+ <SelectTrigger>
226
+ <SelectValue />
227
+ </SelectTrigger>
228
+ <SelectContent>
229
+ <SelectItem value={ALL}>{t('all_statuses')}</SelectItem>
230
+ <SelectItem value="clean">{t('clean')}</SelectItem>
231
+ <SelectItem value="unresolved">{t('unresolved')}</SelectItem>
232
+ <SelectItem value="no_checkpoint">
233
+ {t('no_checkpoint')}
234
+ </SelectItem>
235
+ </SelectContent>
236
+ </Select>
237
+ </div>
238
+ <Tabs defaultValue="windows" className="min-h-0 flex-1">
239
+ <TabsList>
240
+ <TabsTrigger value="windows">{t('windows')}</TabsTrigger>
241
+ <TabsTrigger value="checkpoints">{t('checkpoints')}</TabsTrigger>
242
+ </TabsList>
243
+ <TabsContent value="windows" className="min-h-0">
244
+ <div className="max-h-[58vh] space-y-3 overflow-y-auto pr-1">
245
+ {query.isLoading ? (
246
+ <HistorySkeleton />
247
+ ) : filteredWindowRows.length === 0 ? (
248
+ <EmptyState>{t('no_windows')}</EmptyState>
249
+ ) : (
250
+ filteredWindowRows.map((row) =>
251
+ row.type === 'interval' ? (
252
+ <IntervalRow
253
+ key={row.interval.end_checkpoint_id}
254
+ canCreateTransactions={canCreateTransactions}
255
+ formatDate={formatDate}
256
+ interval={row.interval}
257
+ onReconcile={setReconcileTarget}
258
+ />
259
+ ) : (
260
+ <NoCheckpointRow
261
+ key={row.auditStatus.wallet_id}
262
+ auditStatus={row.auditStatus}
263
+ currency={row.currency}
264
+ walletName={row.walletName}
265
+ />
266
+ )
267
+ )
268
+ )}
269
+ </div>
270
+ </TabsContent>
271
+ <TabsContent value="checkpoints" className="min-h-0">
272
+ <div className="max-h-[58vh] space-y-3 overflow-y-auto pr-1">
273
+ {query.isLoading ? (
274
+ <HistorySkeleton />
275
+ ) : filteredCheckpoints.length === 0 ? (
276
+ <EmptyState>{t('no_checkpoints')}</EmptyState>
277
+ ) : (
278
+ filteredCheckpoints.map((checkpoint) => {
279
+ const wallet = walletById.get(checkpoint.wallet_id);
280
+ const auditStatus = auditStatusByWalletId.get(
281
+ checkpoint.wallet_id
282
+ );
283
+
284
+ return (
285
+ <CheckpointRow
286
+ key={checkpoint.id}
287
+ auditStatus={auditStatus}
288
+ canCreateTransactions={canCreateTransactions}
289
+ checkpoint={checkpoint}
290
+ financePrefix={financePrefix}
291
+ formatDate={formatDate}
292
+ onReconcile={setReconcileTarget}
293
+ walletName={wallet?.name ?? null}
294
+ wsId={wsId}
295
+ />
296
+ );
297
+ })
298
+ )}
299
+ </div>
300
+ </TabsContent>
301
+ </Tabs>
302
+ </DialogContent>
303
+ </Dialog>
304
+ {reconcileTarget && (
305
+ <WalletCheckpointAdjustmentDialog
306
+ checkedAt={reconcileTarget.checkedAt}
307
+ checkpointId={reconcileTarget.checkpointId}
308
+ currency={reconcileTarget.currency}
309
+ onCreated={refresh}
310
+ onOpenChange={(nextOpen) => !nextOpen && setReconcileTarget(null)}
311
+ open={!!reconcileTarget}
312
+ variance={reconcileTarget.variance}
313
+ walletId={reconcileTarget.walletId}
314
+ walletName={reconcileTarget.walletName}
315
+ wsId={wsId}
316
+ />
317
+ )}
318
+ </>
319
+ );
320
+ }
321
+
322
+ function IntervalRow({
323
+ canCreateTransactions,
324
+ formatDate,
325
+ interval,
326
+ onReconcile,
327
+ }: {
328
+ canCreateTransactions: boolean;
329
+ formatDate: (value: string) => string;
330
+ interval: WalletCheckpointHistoryInterval;
331
+ onReconcile: (target: ReconcileTarget) => void;
332
+ }) {
333
+ const t = useTranslations('wallet-checkpoints');
334
+ const status: CheckpointStatus = interval.is_clean ? 'clean' : 'unresolved';
335
+
336
+ return (
337
+ <div
338
+ className={cn(
339
+ 'grid gap-3 rounded-md border p-3',
340
+ interval.is_clean
341
+ ? 'border-dynamic-green/40'
342
+ : 'border-dynamic-yellow/50'
343
+ )}
344
+ >
345
+ <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
346
+ <div>
347
+ <div className="font-medium text-sm">
348
+ {interval.wallet_name ?? interval.wallet_id}
349
+ </div>
350
+ <div className="text-muted-foreground text-xs">
351
+ {formatDate(interval.start_checked_at)} -{' '}
352
+ {formatDate(interval.end_checked_at)}
353
+ </div>
354
+ </div>
355
+ <StatusBadge status={status} />
356
+ </div>
357
+ <div className="grid gap-3 text-sm md:grid-cols-4">
358
+ <Metric label={t('actual_delta')}>
359
+ <WalletCheckpointAmount
360
+ amount={interval.actual_delta}
361
+ currency={interval.currency}
362
+ signDisplay="always"
363
+ />
364
+ </Metric>
365
+ <Metric label={t('ledger_delta')}>
366
+ <WalletCheckpointAmount
367
+ amount={interval.ledger_delta}
368
+ currency={interval.currency}
369
+ signDisplay="always"
370
+ />
371
+ </Metric>
372
+ <Metric label={t('variance')}>
373
+ <WalletCheckpointAmount
374
+ amount={interval.interval_variance}
375
+ currency={interval.currency}
376
+ signDisplay="always"
377
+ />
378
+ </Metric>
379
+ <Metric label={t('transaction_count')}>
380
+ {interval.transaction_count}
381
+ </Metric>
382
+ </div>
383
+ {!interval.is_clean && canCreateTransactions && (
384
+ <div>
385
+ <Button
386
+ size="sm"
387
+ variant="outline"
388
+ onClick={() =>
389
+ onReconcile({
390
+ checkedAt: interval.end_checked_at,
391
+ checkpointId: interval.end_checkpoint_id,
392
+ currency: interval.currency,
393
+ variance: interval.interval_variance,
394
+ walletId: interval.wallet_id,
395
+ walletName: interval.wallet_name ?? interval.wallet_id,
396
+ })
397
+ }
398
+ >
399
+ {t('reconcile')}
400
+ </Button>
401
+ </div>
402
+ )}
403
+ </div>
404
+ );
405
+ }
406
+
407
+ function NoCheckpointRow({
408
+ auditStatus,
409
+ currency,
410
+ walletName,
411
+ }: {
412
+ auditStatus: WalletCheckpointAuditStatus;
413
+ currency: string;
414
+ walletName: string | null;
415
+ }) {
416
+ const t = useTranslations('wallet-checkpoints');
417
+
418
+ return (
419
+ <div className="grid gap-3 rounded-md border border-dashed p-3">
420
+ <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
421
+ <div>
422
+ <div className="font-medium text-sm">
423
+ {walletName ?? auditStatus.wallet_id}
424
+ </div>
425
+ <div className="text-muted-foreground text-xs">
426
+ {t('no_checkpoint_detail')}
427
+ </div>
428
+ </div>
429
+ <StatusBadge status="no_checkpoint" />
430
+ </div>
431
+ <Metric label={t('ledger_balance')}>
432
+ <WalletCheckpointAmount
433
+ amount={auditStatus.ledger_balance}
434
+ currency={currency}
435
+ />
436
+ </Metric>
437
+ </div>
438
+ );
439
+ }
440
+
441
+ function CheckpointRow({
442
+ auditStatus,
443
+ canCreateTransactions,
444
+ checkpoint,
445
+ financePrefix,
446
+ formatDate,
447
+ onReconcile,
448
+ walletName,
449
+ wsId,
450
+ }: {
451
+ auditStatus?: WalletCheckpointAuditStatus;
452
+ canCreateTransactions: boolean;
453
+ checkpoint: WalletCheckpoint;
454
+ financePrefix: string;
455
+ formatDate: (value: string) => string;
456
+ onReconcile: (target: ReconcileTarget) => void;
457
+ walletName: string | null;
458
+ wsId: string;
459
+ }) {
460
+ const t = useTranslations('wallet-checkpoints');
461
+ const isLatest = auditStatus?.latest_checkpoint_id === checkpoint.id;
462
+ const checkpointStatus = getCheckpointStatus(checkpoint, auditStatus);
463
+ const variance = isLatest
464
+ ? (auditStatus?.variance ?? checkpoint.current_variance)
465
+ : checkpoint.current_variance;
466
+
467
+ return (
468
+ <div className="grid gap-3 rounded-md border p-3">
469
+ <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
470
+ <div>
471
+ <div className="font-medium text-sm">
472
+ {walletName ?? checkpoint.wallet_id}
473
+ </div>
474
+ <div className="text-muted-foreground text-xs">
475
+ {formatDate(checkpoint.checked_at)}
476
+ </div>
477
+ </div>
478
+ <div className="flex flex-wrap items-center gap-2">
479
+ <StatusBadge status={checkpointStatus} />
480
+ <Button size="sm" variant="ghost" asChild>
481
+ <Link
482
+ href={`/${wsId}${financePrefix}/wallets/${checkpoint.wallet_id}`}
483
+ >
484
+ <ExternalLink className="mr-2 h-4 w-4" />
485
+ {t('open_wallet')}
486
+ </Link>
487
+ </Button>
488
+ </div>
489
+ </div>
490
+ <div className="grid gap-3 text-sm md:grid-cols-5">
491
+ <Metric label={t('actual_balance')}>
492
+ <WalletCheckpointAmount
493
+ amount={checkpoint.actual_balance}
494
+ currency={checkpoint.currency}
495
+ />
496
+ </Metric>
497
+ <Metric label={t('ledger_at_checkpoint')}>
498
+ <WalletCheckpointAmount
499
+ amount={checkpoint.ledger_balance}
500
+ currency={checkpoint.currency}
501
+ />
502
+ </Metric>
503
+ <Metric label={t('post_checkpoint_delta')}>
504
+ {isLatest && auditStatus ? (
505
+ <WalletCheckpointAmount
506
+ amount={auditStatus.post_checkpoint_delta}
507
+ currency={checkpoint.currency}
508
+ signDisplay="always"
509
+ />
510
+ ) : (
511
+ '-'
512
+ )}
513
+ </Metric>
514
+ <Metric label={t('audited_balance')}>
515
+ {isLatest && auditStatus ? (
516
+ <WalletCheckpointAmount
517
+ amount={auditStatus.audited_balance}
518
+ currency={checkpoint.currency}
519
+ />
520
+ ) : (
521
+ '-'
522
+ )}
523
+ </Metric>
524
+ <Metric label={t('variance')}>
525
+ <WalletCheckpointAmount
526
+ amount={variance}
527
+ currency={checkpoint.currency}
528
+ signDisplay="always"
529
+ />
530
+ </Metric>
531
+ </div>
532
+ {checkpoint.note && (
533
+ <div className="text-muted-foreground text-sm">
534
+ {t('note')}: {checkpoint.note}
535
+ </div>
536
+ )}
537
+ {variance !== 0 && canCreateTransactions && (
538
+ <div>
539
+ <Button
540
+ size="sm"
541
+ variant="outline"
542
+ onClick={() =>
543
+ onReconcile({
544
+ checkedAt: checkpoint.checked_at,
545
+ checkpointId: checkpoint.id,
546
+ currency: checkpoint.currency,
547
+ variance,
548
+ walletId: checkpoint.wallet_id,
549
+ walletName: walletName ?? checkpoint.wallet_id,
550
+ })
551
+ }
552
+ >
553
+ {t('reconcile')}
554
+ </Button>
555
+ </div>
556
+ )}
557
+ </div>
558
+ );
559
+ }
560
+
561
+ function getCheckpointStatus(
562
+ checkpoint: WalletCheckpoint,
563
+ auditStatus?: WalletCheckpointAuditStatus
564
+ ): CheckpointStatus {
565
+ if (auditStatus?.latest_checkpoint_id === checkpoint.id) {
566
+ return auditStatus.status;
567
+ }
568
+
569
+ return checkpoint.current_variance === 0 ? 'clean' : 'unresolved';
570
+ }
571
+
572
+ function StatusBadge({ status }: { status: CheckpointStatus }) {
573
+ const t = useTranslations('wallet-checkpoints');
574
+
575
+ if (status === 'clean') {
576
+ return <Badge variant="secondary">{t('clean')}</Badge>;
577
+ }
578
+
579
+ if (status === 'no_checkpoint') {
580
+ return <Badge variant="outline">{t('no_checkpoint')}</Badge>;
581
+ }
582
+
583
+ return <Badge variant="outline">{t('unresolved')}</Badge>;
584
+ }
585
+
586
+ function Metric({
587
+ children,
588
+ label,
589
+ }: {
590
+ children: React.ReactNode;
591
+ label: string;
592
+ }) {
593
+ return (
594
+ <div>
595
+ <div className="text-muted-foreground text-xs">{label}</div>
596
+ <div className="font-medium">{children}</div>
597
+ </div>
598
+ );
599
+ }
600
+
601
+ function EmptyState({ children }: { children: React.ReactNode }) {
602
+ return (
603
+ <div className="rounded-md border border-dashed p-4 text-muted-foreground text-sm">
604
+ {children}
605
+ </div>
606
+ );
607
+ }
608
+
609
+ function HistorySkeleton() {
610
+ return (
611
+ <div className="space-y-3">
612
+ <Skeleton className="h-24 w-full" />
613
+ <Skeleton className="h-24 w-full" />
614
+ <Skeleton className="h-24 w-full" />
615
+ </div>
616
+ );
617
+ }