@tuturuuu/ui 0.4.1 → 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 (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +5 -5
  3. package/src/components/ui/custom/settings/task-settings.tsx +76 -0
  4. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +126 -0
  5. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  6. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  7. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  8. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  9. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +172 -0
  10. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  11. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  12. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  13. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +196 -0
  14. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  15. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +277 -0
  16. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +189 -0
  17. package/src/components/ui/finance/wallets/query-invalidation.ts +2 -0
  18. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +7 -3
  19. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +10 -0
  20. package/src/components/ui/finance/wallets/wallets-page.test.tsx +7 -0
  21. package/src/components/ui/finance/wallets/wallets-page.tsx +21 -5
  22. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  23. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  24. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  25. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +10 -0
  26. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +141 -0
  27. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +208 -0
  28. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  29. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  30. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +24 -0
  31. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +18 -3
  32. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  33. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  34. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  35. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +411 -323
  36. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  37. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  38. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  39. package/src/hooks/use-task-actions.ts +45 -0
@@ -0,0 +1,52 @@
1
+ import type { Transaction } from '@tuturuuu/types/primitives/Transaction';
2
+
3
+ function getTransferPairKey(transaction: Transaction) {
4
+ const transactionId = transaction.id;
5
+ const linkedTransactionId = transaction.transfer?.linked_transaction_id;
6
+
7
+ if (!transactionId || !linkedTransactionId) return null;
8
+
9
+ return [transactionId, linkedTransactionId].sort().join(':');
10
+ }
11
+
12
+ function preferOriginTransfer(
13
+ current: Transaction,
14
+ candidate: Transaction
15
+ ): Transaction {
16
+ if (candidate.transfer?.is_origin && !current.transfer?.is_origin) {
17
+ return candidate;
18
+ }
19
+
20
+ return current;
21
+ }
22
+
23
+ export function mergeLinkedTransferTransactions(
24
+ transactions: Transaction[]
25
+ ): Transaction[] {
26
+ const mergedTransactions: Transaction[] = [];
27
+ const pairSlotByKey = new Map<string, number>();
28
+
29
+ for (const transaction of transactions) {
30
+ const pairKey = getTransferPairKey(transaction);
31
+
32
+ if (!pairKey) {
33
+ mergedTransactions.push(transaction);
34
+ continue;
35
+ }
36
+
37
+ const existingSlot = pairSlotByKey.get(pairKey);
38
+
39
+ if (existingSlot === undefined) {
40
+ pairSlotByKey.set(pairKey, mergedTransactions.length);
41
+ mergedTransactions.push(transaction);
42
+ continue;
43
+ }
44
+
45
+ mergedTransactions[existingSlot] = preferOriginTransfer(
46
+ mergedTransactions[existingSlot]!,
47
+ transaction
48
+ );
49
+ }
50
+
51
+ return mergedTransactions;
52
+ }
@@ -0,0 +1,172 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery } from '@tanstack/react-query';
4
+ import {
5
+ createTransaction,
6
+ listTransactionCategories,
7
+ } from '@tuturuuu/internal-api/finance';
8
+ import { Button } from '@tuturuuu/ui/button';
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from '@tuturuuu/ui/dialog';
17
+ import { Input } from '@tuturuuu/ui/input';
18
+ import { Label } from '@tuturuuu/ui/label';
19
+ import {
20
+ Select,
21
+ SelectContent,
22
+ SelectItem,
23
+ SelectTrigger,
24
+ SelectValue,
25
+ } from '@tuturuuu/ui/select';
26
+ import { toast } from '@tuturuuu/ui/sonner';
27
+ import { Textarea } from '@tuturuuu/ui/textarea';
28
+ import { formatCurrency } from '@tuturuuu/utils/format';
29
+ import { useTranslations } from 'next-intl';
30
+ import { useMemo, useState } from 'react';
31
+
32
+ const NO_CATEGORY = 'none';
33
+
34
+ export function WalletCheckpointAdjustmentDialog({
35
+ checkedAt,
36
+ currency,
37
+ onCreated,
38
+ onOpenChange,
39
+ open,
40
+ variance,
41
+ walletId,
42
+ walletName,
43
+ wsId,
44
+ }: {
45
+ checkedAt: string;
46
+ currency: string;
47
+ onCreated: () => void;
48
+ onOpenChange: (open: boolean) => void;
49
+ open: boolean;
50
+ variance: number;
51
+ walletId: string;
52
+ walletName: string;
53
+ wsId: string;
54
+ }) {
55
+ const t = useTranslations('wallet-checkpoints');
56
+ const [categoryId, setCategoryId] = useState(NO_CATEGORY);
57
+ const [description, setDescription] = useState(
58
+ t('adjustment_description', {
59
+ date: new Date(checkedAt).toLocaleDateString(),
60
+ wallet: walletName,
61
+ })
62
+ );
63
+ const [takenAt, setTakenAt] = useState(() => {
64
+ const date = new Date(checkedAt);
65
+ const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
66
+ return local.toISOString().slice(0, 16);
67
+ });
68
+ const categoriesQuery = useQuery({
69
+ queryKey: ['transaction-categories', wsId],
70
+ queryFn: () => listTransactionCategories(wsId),
71
+ enabled: open,
72
+ });
73
+ const amountText = useMemo(
74
+ () =>
75
+ formatCurrency(variance, currency, undefined, {
76
+ maximumFractionDigits: currency === 'VND' ? 0 : 6,
77
+ signDisplay: 'always',
78
+ }),
79
+ [currency, variance]
80
+ );
81
+ const mutation = useMutation({
82
+ mutationFn: () =>
83
+ createTransaction(wsId, {
84
+ amount: variance,
85
+ category_id: categoryId === NO_CATEGORY ? undefined : categoryId,
86
+ description,
87
+ origin_wallet_id: walletId,
88
+ report_opt_in: false,
89
+ taken_at: new Date(takenAt).toISOString(),
90
+ }),
91
+ onSuccess: () => {
92
+ toast.success(t('adjustment_created'));
93
+ onCreated();
94
+ onOpenChange(false);
95
+ },
96
+ onError: (error) => {
97
+ toast.error(
98
+ error instanceof Error ? error.message : t('adjustment_create_error')
99
+ );
100
+ },
101
+ });
102
+
103
+ return (
104
+ <Dialog open={open} onOpenChange={onOpenChange}>
105
+ <DialogContent>
106
+ <DialogHeader>
107
+ <DialogTitle>{t('create_adjustment')}</DialogTitle>
108
+ <DialogDescription>
109
+ {t('create_adjustment_description')}
110
+ </DialogDescription>
111
+ </DialogHeader>
112
+ <div className="grid gap-4 py-2">
113
+ <div className="grid gap-2">
114
+ <Label>{t('adjustment_amount')}</Label>
115
+ <Input value={amountText} readOnly />
116
+ </div>
117
+ <div className="grid gap-2">
118
+ <Label htmlFor="checkpoint-adjustment-date">{t('date')}</Label>
119
+ <Input
120
+ id="checkpoint-adjustment-date"
121
+ type="datetime-local"
122
+ value={takenAt}
123
+ onChange={(event) => setTakenAt(event.target.value)}
124
+ />
125
+ </div>
126
+ <div className="grid gap-2">
127
+ <Label>{t('category')}</Label>
128
+ <Select value={categoryId} onValueChange={setCategoryId}>
129
+ <SelectTrigger>
130
+ <SelectValue />
131
+ </SelectTrigger>
132
+ <SelectContent>
133
+ <SelectItem value={NO_CATEGORY}>{t('no_category')}</SelectItem>
134
+ {(categoriesQuery.data ?? [])
135
+ .filter(
136
+ (category): category is typeof category & { id: string } =>
137
+ typeof category.id === 'string'
138
+ )
139
+ .map((category) => (
140
+ <SelectItem key={category.id} value={category.id}>
141
+ {category.name}
142
+ </SelectItem>
143
+ ))}
144
+ </SelectContent>
145
+ </Select>
146
+ </div>
147
+ <div className="grid gap-2">
148
+ <Label htmlFor="checkpoint-adjustment-description">
149
+ {t('description')}
150
+ </Label>
151
+ <Textarea
152
+ id="checkpoint-adjustment-description"
153
+ value={description}
154
+ onChange={(event) => setDescription(event.target.value)}
155
+ />
156
+ </div>
157
+ </div>
158
+ <DialogFooter>
159
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
160
+ {t('cancel')}
161
+ </Button>
162
+ <Button
163
+ disabled={mutation.isPending || variance === 0}
164
+ onClick={() => mutation.mutate()}
165
+ >
166
+ {mutation.isPending ? t('creating') : t('create_adjustment')}
167
+ </Button>
168
+ </DialogFooter>
169
+ </DialogContent>
170
+ </Dialog>
171
+ );
172
+ }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ import { formatCurrency } from '@tuturuuu/utils/format';
4
+ import {
5
+ FINANCE_HIDDEN_AMOUNT,
6
+ useFinanceConfidentialVisibility,
7
+ } from '../../shared/use-finance-confidential-visibility';
8
+
9
+ export function WalletCheckpointAmount({
10
+ amount,
11
+ currency,
12
+ signDisplay = 'auto',
13
+ }: {
14
+ amount: number;
15
+ currency: string;
16
+ signDisplay?: Intl.NumberFormatOptions['signDisplay'];
17
+ }) {
18
+ const { isConfidential } = useFinanceConfidentialVisibility();
19
+
20
+ if (isConfidential) {
21
+ return <span>{FINANCE_HIDDEN_AMOUNT}</span>;
22
+ }
23
+
24
+ return (
25
+ <span>
26
+ {formatCurrency(amount, currency, undefined, {
27
+ maximumFractionDigits: currency === 'VND' ? 0 : 6,
28
+ signDisplay,
29
+ })}
30
+ </span>
31
+ );
32
+ }
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@tuturuuu/ui/button';
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '@tuturuuu/ui/dialog';
12
+ import { useTranslations } from 'next-intl';
13
+
14
+ export function WalletCheckpointDeleteDialog({
15
+ isPending,
16
+ onConfirm,
17
+ onOpenChange,
18
+ open,
19
+ }: {
20
+ isPending: boolean;
21
+ onConfirm: () => void;
22
+ onOpenChange: (open: boolean) => void;
23
+ open: boolean;
24
+ }) {
25
+ const t = useTranslations('wallet-checkpoints');
26
+ return (
27
+ <Dialog open={open} onOpenChange={onOpenChange}>
28
+ <DialogContent>
29
+ <DialogHeader>
30
+ <DialogTitle>{t('delete_checkpoint')}</DialogTitle>
31
+ <DialogDescription>
32
+ {t('delete_checkpoint_description')}
33
+ </DialogDescription>
34
+ </DialogHeader>
35
+ <DialogFooter>
36
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
37
+ {t('cancel')}
38
+ </Button>
39
+ <Button
40
+ variant="destructive"
41
+ disabled={isPending}
42
+ onClick={onConfirm}
43
+ >
44
+ {isPending ? t('deleting') : t('delete')}
45
+ </Button>
46
+ </DialogFooter>
47
+ </DialogContent>
48
+ </Dialog>
49
+ );
50
+ }
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ import type { WalletCheckpoint } from '@tuturuuu/internal-api/finance';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@tuturuuu/ui/dialog';
13
+ import { Input } from '@tuturuuu/ui/input';
14
+ import { Label } from '@tuturuuu/ui/label';
15
+ import { Textarea } from '@tuturuuu/ui/textarea';
16
+ import { useTranslations } from 'next-intl';
17
+ import { useEffect, useMemo, useState } from 'react';
18
+
19
+ function toLocalInputValue(value?: string) {
20
+ const date = value ? new Date(value) : new Date();
21
+ const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
22
+ return local.toISOString().slice(0, 16);
23
+ }
24
+
25
+ function toIsoTimestamp(value: string) {
26
+ const date = new Date(value);
27
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null;
28
+ }
29
+
30
+ export function WalletCheckpointDialog({
31
+ checkpoint,
32
+ currency,
33
+ isPending,
34
+ mode,
35
+ onOpenChange,
36
+ onSubmit,
37
+ open,
38
+ }: {
39
+ checkpoint?: WalletCheckpoint | null;
40
+ currency: string;
41
+ isPending: boolean;
42
+ mode: 'create' | 'edit';
43
+ onOpenChange: (open: boolean) => void;
44
+ onSubmit: (payload: {
45
+ actual_balance: number;
46
+ checked_at: string;
47
+ note: string | null;
48
+ }) => void;
49
+ open: boolean;
50
+ }) {
51
+ const t = useTranslations('wallet-checkpoints');
52
+ const [amount, setAmount] = useState('');
53
+ const [checkedAt, setCheckedAt] = useState(toLocalInputValue());
54
+ const [note, setNote] = useState('');
55
+
56
+ useEffect(() => {
57
+ if (!open) return;
58
+ setAmount(checkpoint ? String(checkpoint.actual_balance) : '');
59
+ setCheckedAt(toLocalInputValue(checkpoint?.checked_at));
60
+ setNote(checkpoint?.note ?? '');
61
+ }, [checkpoint, open]);
62
+
63
+ const parsedAmount = useMemo(() => Number(amount), [amount]);
64
+ const isoTimestamp = useMemo(() => toIsoTimestamp(checkedAt), [checkedAt]);
65
+ const canSubmit = Number.isFinite(parsedAmount) && !!isoTimestamp;
66
+
67
+ return (
68
+ <Dialog open={open} onOpenChange={onOpenChange}>
69
+ <DialogContent>
70
+ <DialogHeader>
71
+ <DialogTitle>
72
+ {mode === 'edit' ? t('edit_checkpoint') : t('record_checkpoint')}
73
+ </DialogTitle>
74
+ <DialogDescription>
75
+ {mode === 'edit'
76
+ ? t('edit_checkpoint_description')
77
+ : t('record_checkpoint_description')}
78
+ </DialogDescription>
79
+ </DialogHeader>
80
+ <div className="grid gap-4 py-2">
81
+ <div className="grid gap-2">
82
+ <Label htmlFor="checkpoint-amount">
83
+ {t('actual_balance_with_currency', { currency })}
84
+ </Label>
85
+ <Input
86
+ id="checkpoint-amount"
87
+ inputMode="decimal"
88
+ value={amount}
89
+ onChange={(event) => setAmount(event.target.value)}
90
+ placeholder="0"
91
+ />
92
+ </div>
93
+ <div className="grid gap-2">
94
+ <Label htmlFor="checkpoint-checked-at">{t('checked_at')}</Label>
95
+ <Input
96
+ id="checkpoint-checked-at"
97
+ type="datetime-local"
98
+ value={checkedAt}
99
+ onChange={(event) => setCheckedAt(event.target.value)}
100
+ />
101
+ </div>
102
+ <div className="grid gap-2">
103
+ <Label htmlFor="checkpoint-note">{t('note')}</Label>
104
+ <Textarea
105
+ id="checkpoint-note"
106
+ value={note}
107
+ maxLength={500}
108
+ onChange={(event) => setNote(event.target.value)}
109
+ />
110
+ </div>
111
+ </div>
112
+ <DialogFooter>
113
+ <Button
114
+ type="button"
115
+ variant="outline"
116
+ onClick={() => onOpenChange(false)}
117
+ >
118
+ {t('cancel')}
119
+ </Button>
120
+ <Button
121
+ type="button"
122
+ disabled={!canSubmit || isPending}
123
+ onClick={() => {
124
+ if (!isoTimestamp) return;
125
+ onSubmit({
126
+ actual_balance: parsedAmount,
127
+ checked_at: isoTimestamp,
128
+ note: note.trim() || null,
129
+ });
130
+ }}
131
+ >
132
+ {isPending ? t('saving') : t('save_checkpoint')}
133
+ </Button>
134
+ </DialogFooter>
135
+ </DialogContent>
136
+ </Dialog>
137
+ );
138
+ }
@@ -0,0 +1,196 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { ClipboardCheck, Plus } from '@tuturuuu/icons';
5
+ import {
6
+ createWalletCheckpoint,
7
+ deleteWalletCheckpoint,
8
+ listWalletCheckpoints,
9
+ updateWalletCheckpoint,
10
+ type WalletCheckpoint,
11
+ type WalletCheckpointInterval,
12
+ } from '@tuturuuu/internal-api/finance';
13
+ import { Button } from '@tuturuuu/ui/button';
14
+ import { Card } from '@tuturuuu/ui/card';
15
+ import { Separator } from '@tuturuuu/ui/separator';
16
+ import { Skeleton } from '@tuturuuu/ui/skeleton';
17
+ import { toast } from '@tuturuuu/ui/sonner';
18
+ import { useLocale, useTranslations } from 'next-intl';
19
+ import { useState } from 'react';
20
+ import { invalidateWalletMutationQueries } from '../query-invalidation';
21
+ import { WalletCheckpointAdjustmentDialog } from './wallet-checkpoint-adjustment-dialog';
22
+ import { WalletCheckpointDeleteDialog } from './wallet-checkpoint-delete-dialog';
23
+ import { WalletCheckpointDialog } from './wallet-checkpoint-dialog';
24
+ import {
25
+ LatestCheckpoint,
26
+ WalletCheckpointIntervals,
27
+ WalletCheckpointTimeline,
28
+ } from './wallet-checkpoint-sections';
29
+
30
+ function checkpointKey(wsId: string, walletId: string) {
31
+ return ['wallet-checkpoints', wsId, walletId] as const;
32
+ }
33
+
34
+ export function WalletCheckpointPanel({
35
+ canCreateTransactions,
36
+ canUpdateWallets,
37
+ currency,
38
+ walletId,
39
+ walletName,
40
+ wsId,
41
+ }: {
42
+ canCreateTransactions: boolean;
43
+ canUpdateWallets: boolean;
44
+ currency: string;
45
+ walletId: string;
46
+ walletName: string;
47
+ wsId: string;
48
+ }) {
49
+ const t = useTranslations('wallet-checkpoints');
50
+ const locale = useLocale();
51
+ const queryClient = useQueryClient();
52
+ const [createOpen, setCreateOpen] = useState(false);
53
+ const [editing, setEditing] = useState<WalletCheckpoint | null>(null);
54
+ const [deleting, setDeleting] = useState<WalletCheckpoint | null>(null);
55
+ const [adjusting, setAdjusting] = useState<WalletCheckpointInterval | null>(
56
+ null
57
+ );
58
+ const query = useQuery({
59
+ queryKey: checkpointKey(wsId, walletId),
60
+ queryFn: () => listWalletCheckpoints(wsId, walletId, { limit: 50 }),
61
+ });
62
+
63
+ const refresh = () => {
64
+ queryClient.invalidateQueries({ queryKey: checkpointKey(wsId, walletId) });
65
+ invalidateWalletMutationQueries(queryClient, wsId);
66
+ };
67
+ const createMutation = useMutation({
68
+ mutationFn: (payload: Parameters<typeof createWalletCheckpoint>[2]) =>
69
+ createWalletCheckpoint(wsId, walletId, payload),
70
+ onSuccess: () => {
71
+ toast.success(t('checkpoint_saved'));
72
+ setCreateOpen(false);
73
+ refresh();
74
+ },
75
+ onError: (error) =>
76
+ toast.error(error instanceof Error ? error.message : t('save_error')),
77
+ });
78
+ const updateMutation = useMutation({
79
+ mutationFn: (payload: Parameters<typeof updateWalletCheckpoint>[3]) =>
80
+ editing
81
+ ? updateWalletCheckpoint(wsId, walletId, editing.id, payload)
82
+ : Promise.reject(new Error(t('checkpoint_not_found'))),
83
+ onSuccess: () => {
84
+ toast.success(t('checkpoint_saved'));
85
+ setEditing(null);
86
+ refresh();
87
+ },
88
+ onError: (error) =>
89
+ toast.error(error instanceof Error ? error.message : t('save_error')),
90
+ });
91
+ const deleteMutation = useMutation({
92
+ mutationFn: () =>
93
+ deleting
94
+ ? deleteWalletCheckpoint(wsId, walletId, deleting.id)
95
+ : Promise.reject(new Error(t('checkpoint_not_found'))),
96
+ onSuccess: () => {
97
+ toast.success(t('checkpoint_deleted'));
98
+ setDeleting(null);
99
+ refresh();
100
+ },
101
+ onError: (error) =>
102
+ toast.error(error instanceof Error ? error.message : t('delete_error')),
103
+ });
104
+ const formatDate = (value: string) =>
105
+ new Intl.DateTimeFormat(locale, {
106
+ dateStyle: 'medium',
107
+ timeStyle: 'short',
108
+ }).format(new Date(value));
109
+
110
+ return (
111
+ <Card className="grid gap-4 p-4">
112
+ <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
113
+ <div className="space-y-1">
114
+ <div className="flex items-center gap-2 font-semibold text-lg">
115
+ <ClipboardCheck className="h-5 w-5" />
116
+ {t('title')}
117
+ </div>
118
+ <p className="text-muted-foreground text-sm">{t('description')}</p>
119
+ </div>
120
+ {canUpdateWallets && (
121
+ <Button size="sm" onClick={() => setCreateOpen(true)}>
122
+ <Plus className="mr-2 h-4 w-4" />
123
+ {t('record_checkpoint')}
124
+ </Button>
125
+ )}
126
+ </div>
127
+ <Separator />
128
+ {query.isLoading ? (
129
+ <div className="space-y-2">
130
+ <Skeleton className="h-16 w-full" />
131
+ <Skeleton className="h-24 w-full" />
132
+ </div>
133
+ ) : query.data?.latest ? (
134
+ <LatestCheckpoint
135
+ checkpoint={query.data.latest}
136
+ formatDate={formatDate}
137
+ currency={currency}
138
+ />
139
+ ) : (
140
+ <div className="rounded-md border border-dashed p-4 text-muted-foreground text-sm">
141
+ {t('empty_state')}
142
+ </div>
143
+ )}
144
+ <WalletCheckpointIntervals
145
+ canCreateTransactions={canCreateTransactions}
146
+ currency={currency}
147
+ formatDate={formatDate}
148
+ intervals={query.data?.intervals ?? []}
149
+ onAdjust={setAdjusting}
150
+ />
151
+ <WalletCheckpointTimeline
152
+ canUpdateWallets={canUpdateWallets}
153
+ checkpoints={query.data?.data ?? []}
154
+ formatDate={formatDate}
155
+ onDelete={setDeleting}
156
+ onEdit={setEditing}
157
+ />
158
+ <WalletCheckpointDialog
159
+ currency={currency}
160
+ isPending={createMutation.isPending}
161
+ mode="create"
162
+ open={createOpen}
163
+ onOpenChange={setCreateOpen}
164
+ onSubmit={(payload) => createMutation.mutate(payload)}
165
+ />
166
+ <WalletCheckpointDialog
167
+ checkpoint={editing}
168
+ currency={currency}
169
+ isPending={updateMutation.isPending}
170
+ mode="edit"
171
+ open={!!editing}
172
+ onOpenChange={(open) => !open && setEditing(null)}
173
+ onSubmit={(payload) => updateMutation.mutate(payload)}
174
+ />
175
+ <WalletCheckpointDeleteDialog
176
+ open={!!deleting}
177
+ onOpenChange={(open) => !open && setDeleting(null)}
178
+ isPending={deleteMutation.isPending}
179
+ onConfirm={() => deleteMutation.mutate()}
180
+ />
181
+ {adjusting && (
182
+ <WalletCheckpointAdjustmentDialog
183
+ checkedAt={adjusting.end_checked_at}
184
+ currency={currency}
185
+ onCreated={refresh}
186
+ onOpenChange={(open) => !open && setAdjusting(null)}
187
+ open={!!adjusting}
188
+ variance={adjusting.interval_variance}
189
+ walletId={walletId}
190
+ walletName={walletName}
191
+ wsId={wsId}
192
+ />
193
+ )}
194
+ </Card>
195
+ );
196
+ }