@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.
- package/CHANGELOG.md +43 -0
- package/package.json +41 -34
- package/src/components/ui/currency-input.tsx +65 -23
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
- package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
- package/src/components/ui/custom/combobox.test.tsx +141 -0
- package/src/components/ui/custom/combobox.tsx +105 -36
- package/src/components/ui/custom/settings/task-settings.tsx +126 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +146 -0
- package/src/components/ui/custom/sidebar-context.tsx +68 -6
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
- package/src/components/ui/finance/finance-layout.tsx +2 -4
- package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
- package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
- package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
- package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
- package/src/components/ui/finance/transactions/form-types.ts +23 -0
- package/src/components/ui/finance/transactions/form.tsx +81 -22
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
- package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
- package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
- package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
- package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +219 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +197 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +541 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +362 -0
- package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
- package/src/components/ui/finance/wallets/columns.test.ts +56 -0
- package/src/components/ui/finance/wallets/columns.tsx +196 -43
- package/src/components/ui/finance/wallets/form.test.tsx +79 -14
- package/src/components/ui/finance/wallets/form.tsx +41 -197
- package/src/components/ui/finance/wallets/query-invalidation.ts +3 -0
- package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
- package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
- package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +71 -5
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +52 -35
- package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
- package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
- package/src/components/ui/finance/wallets/wallets-page.test.tsx +117 -36
- package/src/components/ui/finance/wallets/wallets-page.tsx +40 -64
- package/src/components/ui/storefront/accent-button.tsx +33 -0
- package/src/components/ui/storefront/cart-summary.tsx +140 -0
- package/src/components/ui/storefront/empty-listings.tsx +32 -0
- package/src/components/ui/storefront/hero-panel.tsx +70 -0
- package/src/components/ui/storefront/image-panel.tsx +40 -0
- package/src/components/ui/storefront/index.ts +12 -0
- package/src/components/ui/storefront/listing-card.tsx +129 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
- package/src/components/ui/storefront/storefront-surface.tsx +235 -0
- package/src/components/ui/storefront/types.ts +99 -0
- package/src/components/ui/storefront/utils.ts +90 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
- package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
- package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +124 -7
- package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
- package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
- package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +268 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +243 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +41 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +157 -102
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +959 -340
- package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
- package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
- package/src/hooks/use-task-actions.ts +45 -0
- package/src/hooks/useBoardRealtime.ts +54 -1
- package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
- package/src/hooks/useTaskUserRealtime.ts +338 -0
|
@@ -83,35 +83,8 @@ export function TransactionCard({
|
|
|
83
83
|
const [isHovered, setIsHovered] = useState(false);
|
|
84
84
|
const { isConfidential: areNumbersHidden } =
|
|
85
85
|
useFinanceConfidentialVisibility();
|
|
86
|
-
const effectiveCurrency = transaction.wallet_currency || currency;
|
|
87
|
-
const isExpense = (transaction.amount || 0) < 0;
|
|
88
86
|
const isTransfer = !!transaction.transfer;
|
|
89
87
|
|
|
90
|
-
// Currency conversion for foreign-currency transactions
|
|
91
|
-
const isForeignCurrency =
|
|
92
|
-
effectiveCurrency.toUpperCase() !== currency.toUpperCase();
|
|
93
|
-
const { data: exchangeRateData } = useExchangeRates();
|
|
94
|
-
const convertedAmount = useMemo(() => {
|
|
95
|
-
if (
|
|
96
|
-
!isForeignCurrency ||
|
|
97
|
-
transaction.amount == null ||
|
|
98
|
-
!exchangeRateData?.data
|
|
99
|
-
)
|
|
100
|
-
return null;
|
|
101
|
-
return convertCurrency(
|
|
102
|
-
transaction.amount,
|
|
103
|
-
effectiveCurrency,
|
|
104
|
-
currency,
|
|
105
|
-
exchangeRateData.data
|
|
106
|
-
);
|
|
107
|
-
}, [
|
|
108
|
-
isForeignCurrency,
|
|
109
|
-
transaction.amount,
|
|
110
|
-
effectiveCurrency,
|
|
111
|
-
currency,
|
|
112
|
-
exchangeRateData?.data,
|
|
113
|
-
]);
|
|
114
|
-
|
|
115
88
|
// Check if transaction is confidential
|
|
116
89
|
const isConfidential =
|
|
117
90
|
transaction.is_amount_confidential ||
|
|
@@ -154,6 +127,65 @@ export function TransactionCard({
|
|
|
154
127
|
return wallets.find((w) => w.id === transaction.transfer?.linked_wallet_id);
|
|
155
128
|
}, [transaction.transfer?.linked_wallet_id, wallets]);
|
|
156
129
|
|
|
130
|
+
const transferDisplay = transaction.transfer
|
|
131
|
+
? transaction.transfer.is_origin
|
|
132
|
+
? {
|
|
133
|
+
amount: transaction.amount,
|
|
134
|
+
amountCurrency: transaction.wallet_currency,
|
|
135
|
+
destinationIcon: linkedWallet?.icon,
|
|
136
|
+
destinationImageSrc: linkedWallet?.image_src,
|
|
137
|
+
destinationWalletId: transaction.transfer.linked_wallet_id,
|
|
138
|
+
destinationWalletName: transaction.transfer.linked_wallet_name,
|
|
139
|
+
originIcon: wallet?.icon,
|
|
140
|
+
originImageSrc: wallet?.image_src,
|
|
141
|
+
originWalletId: transaction.wallet_id,
|
|
142
|
+
originWalletName: transaction.wallet,
|
|
143
|
+
secondaryAmount: transaction.transfer.linked_amount,
|
|
144
|
+
secondaryCurrency: transaction.transfer.linked_wallet_currency,
|
|
145
|
+
}
|
|
146
|
+
: {
|
|
147
|
+
amount: transaction.transfer.linked_amount ?? transaction.amount,
|
|
148
|
+
amountCurrency:
|
|
149
|
+
transaction.transfer.linked_wallet_currency ||
|
|
150
|
+
transaction.wallet_currency,
|
|
151
|
+
destinationIcon: wallet?.icon,
|
|
152
|
+
destinationImageSrc: wallet?.image_src,
|
|
153
|
+
destinationWalletId: transaction.wallet_id,
|
|
154
|
+
destinationWalletName: transaction.wallet,
|
|
155
|
+
originIcon: linkedWallet?.icon,
|
|
156
|
+
originImageSrc: linkedWallet?.image_src,
|
|
157
|
+
originWalletId: transaction.transfer.linked_wallet_id,
|
|
158
|
+
originWalletName: transaction.transfer.linked_wallet_name,
|
|
159
|
+
secondaryAmount: transaction.amount,
|
|
160
|
+
secondaryCurrency: transaction.wallet_currency,
|
|
161
|
+
}
|
|
162
|
+
: null;
|
|
163
|
+
const displayAmount = transferDisplay?.amount ?? transaction.amount;
|
|
164
|
+
const effectiveCurrency =
|
|
165
|
+
transferDisplay?.amountCurrency || transaction.wallet_currency || currency;
|
|
166
|
+
const isExpense = (displayAmount || 0) < 0;
|
|
167
|
+
|
|
168
|
+
// Currency conversion for foreign-currency transactions
|
|
169
|
+
const isForeignCurrency =
|
|
170
|
+
effectiveCurrency.toUpperCase() !== currency.toUpperCase();
|
|
171
|
+
const { data: exchangeRateData } = useExchangeRates();
|
|
172
|
+
const convertedAmount = useMemo(() => {
|
|
173
|
+
if (!isForeignCurrency || displayAmount == null || !exchangeRateData?.data)
|
|
174
|
+
return null;
|
|
175
|
+
return convertCurrency(
|
|
176
|
+
displayAmount,
|
|
177
|
+
effectiveCurrency,
|
|
178
|
+
currency,
|
|
179
|
+
exchangeRateData.data
|
|
180
|
+
);
|
|
181
|
+
}, [
|
|
182
|
+
isForeignCurrency,
|
|
183
|
+
displayAmount,
|
|
184
|
+
effectiveCurrency,
|
|
185
|
+
currency,
|
|
186
|
+
exchangeRateData?.data,
|
|
187
|
+
]);
|
|
188
|
+
|
|
157
189
|
// Determine if we should use custom styling
|
|
158
190
|
const hasCustomStyling = Boolean(customColorStyles);
|
|
159
191
|
|
|
@@ -331,37 +363,37 @@ export function TransactionCard({
|
|
|
331
363
|
{transaction.category}
|
|
332
364
|
</Badge>
|
|
333
365
|
) : null}
|
|
334
|
-
{transaction.wallet && (
|
|
366
|
+
{(transaction.wallet || transferDisplay) && (
|
|
335
367
|
<div className="flex items-center gap-1">
|
|
336
|
-
{isTransfer &&
|
|
368
|
+
{isTransfer && transferDisplay ? (
|
|
337
369
|
<div className="flex items-center rounded-full border border-dynamic-blue/20 bg-dynamic-blue/5 py-0.5 pr-1 pl-1">
|
|
338
370
|
<Link
|
|
339
|
-
href={`/${wsId}${financeHref(`/wallets/${
|
|
371
|
+
href={`/${wsId}${financeHref(`/wallets/${transferDisplay.originWalletId}`)}`}
|
|
340
372
|
onClick={(e) => e.stopPropagation()}
|
|
341
373
|
>
|
|
342
374
|
<span className="flex items-center gap-1 rounded-full px-1.5 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-dynamic-blue/10 hover:text-foreground sm:text-xs">
|
|
343
375
|
<WalletIconDisplay
|
|
344
|
-
icon={
|
|
345
|
-
imageSrc={
|
|
376
|
+
icon={transferDisplay.originIcon}
|
|
377
|
+
imageSrc={transferDisplay.originImageSrc}
|
|
346
378
|
size="sm"
|
|
347
379
|
className="h-3 w-3"
|
|
348
380
|
/>
|
|
349
|
-
{
|
|
381
|
+
{transferDisplay.originWalletName}
|
|
350
382
|
</span>
|
|
351
383
|
</Link>
|
|
352
384
|
<ArrowRight className="mx-0.5 h-3 w-3 shrink-0 text-dynamic-blue" />
|
|
353
385
|
<Link
|
|
354
|
-
href={`/${wsId}${financeHref(`/wallets/${
|
|
386
|
+
href={`/${wsId}${financeHref(`/wallets/${transferDisplay.destinationWalletId}`)}`}
|
|
355
387
|
onClick={(e) => e.stopPropagation()}
|
|
356
388
|
>
|
|
357
389
|
<span className="flex items-center gap-1 rounded-full px-1.5 py-0.5 font-medium text-[11px] text-dynamic-blue transition-colors hover:bg-dynamic-blue/10 sm:text-xs">
|
|
358
390
|
<WalletIconDisplay
|
|
359
|
-
icon={
|
|
360
|
-
imageSrc={
|
|
391
|
+
icon={transferDisplay.destinationIcon}
|
|
392
|
+
imageSrc={transferDisplay.destinationImageSrc}
|
|
361
393
|
size="sm"
|
|
362
394
|
className="h-3 w-3"
|
|
363
395
|
/>
|
|
364
|
-
{
|
|
396
|
+
{transferDisplay.destinationWalletName}
|
|
365
397
|
</span>
|
|
366
398
|
</Link>
|
|
367
399
|
</div>
|
|
@@ -401,7 +433,7 @@ export function TransactionCard({
|
|
|
401
433
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
|
402
434
|
<div className="flex flex-col items-end">
|
|
403
435
|
<ConfidentialAmount
|
|
404
|
-
amount={
|
|
436
|
+
amount={displayAmount ?? null}
|
|
405
437
|
isConfidential={transaction.is_amount_confidential || false}
|
|
406
438
|
currency={effectiveCurrency}
|
|
407
439
|
className={cn(
|
|
@@ -426,16 +458,16 @@ export function TransactionCard({
|
|
|
426
458
|
}
|
|
427
459
|
/>
|
|
428
460
|
{isTransfer &&
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
461
|
+
transferDisplay?.secondaryAmount != null &&
|
|
462
|
+
transferDisplay.secondaryCurrency &&
|
|
463
|
+
transferDisplay.secondaryCurrency.toUpperCase() !==
|
|
432
464
|
effectiveCurrency.toUpperCase() && (
|
|
433
465
|
<span className="text-[10px] text-dynamic-blue tabular-nums sm:text-xs">
|
|
434
466
|
{areNumbersHidden
|
|
435
467
|
? FINANCE_HIDDEN_AMOUNT
|
|
436
468
|
: formatCurrency(
|
|
437
|
-
Math.abs(
|
|
438
|
-
|
|
469
|
+
Math.abs(transferDisplay.secondaryAmount),
|
|
470
|
+
transferDisplay.secondaryCurrency,
|
|
439
471
|
undefined,
|
|
440
472
|
{ signDisplay: 'always' }
|
|
441
473
|
)}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Transaction } from '@tuturuuu/types/primitives/Transaction';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { mergeLinkedTransferTransactions } from './transfer-merge';
|
|
4
|
+
|
|
5
|
+
function transaction(
|
|
6
|
+
id: string,
|
|
7
|
+
amount: number,
|
|
8
|
+
transfer?: Transaction['transfer']
|
|
9
|
+
): Transaction {
|
|
10
|
+
return {
|
|
11
|
+
amount,
|
|
12
|
+
id,
|
|
13
|
+
taken_at: '2026-06-11T00:00:00.000Z',
|
|
14
|
+
wallet_id: `wallet-${id}`,
|
|
15
|
+
wallet: `Wallet ${id}`,
|
|
16
|
+
transfer,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const originTransfer = {
|
|
21
|
+
is_origin: true,
|
|
22
|
+
linked_amount: 100,
|
|
23
|
+
linked_transaction_id: 'destination',
|
|
24
|
+
linked_wallet_id: 'wallet-destination',
|
|
25
|
+
linked_wallet_name: 'Destination Wallet',
|
|
26
|
+
} satisfies Transaction['transfer'];
|
|
27
|
+
|
|
28
|
+
const destinationTransfer = {
|
|
29
|
+
is_origin: false,
|
|
30
|
+
linked_amount: -100,
|
|
31
|
+
linked_transaction_id: 'origin',
|
|
32
|
+
linked_wallet_id: 'wallet-origin',
|
|
33
|
+
linked_wallet_name: 'Origin Wallet',
|
|
34
|
+
} satisfies Transaction['transfer'];
|
|
35
|
+
|
|
36
|
+
describe('mergeLinkedTransferTransactions', () => {
|
|
37
|
+
it('collapses a linked origin and destination pair into one transfer row', () => {
|
|
38
|
+
const origin = transaction('origin', -100, originTransfer);
|
|
39
|
+
const destination = transaction('destination', 100, destinationTransfer);
|
|
40
|
+
|
|
41
|
+
expect(mergeLinkedTransferTransactions([origin, destination])).toEqual([
|
|
42
|
+
origin,
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('keeps the origin transfer when the destination leg appears first', () => {
|
|
47
|
+
const origin = transaction('origin', -100, originTransfer);
|
|
48
|
+
const destination = transaction('destination', 100, destinationTransfer);
|
|
49
|
+
|
|
50
|
+
expect(mergeLinkedTransferTransactions([destination, origin])).toEqual([
|
|
51
|
+
origin,
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps an unmatched transfer leg visible', () => {
|
|
56
|
+
const destination = transaction('destination', 100, destinationTransfer);
|
|
57
|
+
|
|
58
|
+
expect(mergeLinkedTransferTransactions([destination])).toEqual([
|
|
59
|
+
destination,
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not merge unrelated rows with matching amounts', () => {
|
|
64
|
+
const expense = transaction('expense', -100);
|
|
65
|
+
const income = transaction('income', 100);
|
|
66
|
+
|
|
67
|
+
expect(mergeLinkedTransferTransactions([expense, income])).toEqual([
|
|
68
|
+
expense,
|
|
69
|
+
income,
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('keeps unrelated row order while using the first pair slot', () => {
|
|
74
|
+
const first = transaction('first', 1);
|
|
75
|
+
const second = transaction('second', 2);
|
|
76
|
+
const third = transaction('third', 3);
|
|
77
|
+
const origin = transaction('origin', -100, originTransfer);
|
|
78
|
+
const destination = transaction('destination', 100, destinationTransfer);
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
mergeLinkedTransferTransactions([
|
|
82
|
+
first,
|
|
83
|
+
destination,
|
|
84
|
+
second,
|
|
85
|
+
origin,
|
|
86
|
+
third,
|
|
87
|
+
])
|
|
88
|
+
).toEqual([first, origin, second, third]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -20,10 +20,12 @@ import { getCurrencyLocale } from '@tuturuuu/utils/currencies';
|
|
|
20
20
|
import { cn } from '@tuturuuu/utils/format';
|
|
21
21
|
import { useTranslations } from 'next-intl';
|
|
22
22
|
import { useState } from 'react';
|
|
23
|
+
import { useFinanceBalanceMode } from '../shared/use-finance-balance-mode';
|
|
23
24
|
import {
|
|
24
25
|
FINANCE_HIDDEN_AMOUNT,
|
|
25
26
|
useFinanceConfidentialVisibility,
|
|
26
27
|
} from '../shared/use-finance-confidential-visibility';
|
|
28
|
+
import { resolveWalletBalanceForMode } from '../shared/wallet-balance-mode';
|
|
27
29
|
|
|
28
30
|
interface WalletFilterProps {
|
|
29
31
|
wsId: string;
|
|
@@ -45,9 +47,11 @@ export function WalletFilter({
|
|
|
45
47
|
className,
|
|
46
48
|
}: WalletFilterProps) {
|
|
47
49
|
const t = useTranslations();
|
|
50
|
+
const checkpointT = useTranslations('wallet-checkpoints');
|
|
48
51
|
const [isOpen, setIsOpen] = useState(false);
|
|
49
52
|
const { isConfidential: areNumbersHidden } =
|
|
50
53
|
useFinanceConfidentialVisibility();
|
|
54
|
+
const { isAuditedMode } = useFinanceBalanceMode();
|
|
51
55
|
|
|
52
56
|
const hasActiveFilters = selectedWalletIds.length > 0;
|
|
53
57
|
|
|
@@ -135,6 +139,14 @@ export function WalletFilter({
|
|
|
135
139
|
})
|
|
136
140
|
.map((wallet) => {
|
|
137
141
|
const isSelected = selectedWalletIds.includes(wallet.id);
|
|
142
|
+
const { displayBalance, usesAuditedBalance } =
|
|
143
|
+
resolveWalletBalanceForMode(
|
|
144
|
+
wallet,
|
|
145
|
+
isAuditedMode ? 'audited' : 'ledger'
|
|
146
|
+
);
|
|
147
|
+
const balanceLabel = usesAuditedBalance
|
|
148
|
+
? checkpointT('audited')
|
|
149
|
+
: checkpointT('ledger');
|
|
138
150
|
|
|
139
151
|
return (
|
|
140
152
|
<CommandItem
|
|
@@ -161,7 +173,7 @@ export function WalletFilter({
|
|
|
161
173
|
<span className="text-muted-foreground text-xs">
|
|
162
174
|
{areNumbersHidden
|
|
163
175
|
? FINANCE_HIDDEN_AMOUNT
|
|
164
|
-
: Intl.NumberFormat(
|
|
176
|
+
: `${balanceLabel}: ${Intl.NumberFormat(
|
|
165
177
|
getCurrencyLocale(
|
|
166
178
|
wallet.currency || 'USD'
|
|
167
179
|
),
|
|
@@ -171,7 +183,14 @@ export function WalletFilter({
|
|
|
171
183
|
minimumFractionDigits: 0,
|
|
172
184
|
maximumFractionDigits: 0,
|
|
173
185
|
}
|
|
174
|
-
).format(
|
|
186
|
+
).format(displayBalance)}`}
|
|
187
|
+
{!areNumbersHidden &&
|
|
188
|
+
isAuditedMode &&
|
|
189
|
+
wallet.audit_status === 'no_checkpoint' && (
|
|
190
|
+
<span className="ml-1">
|
|
191
|
+
{checkpointT('no_checkpoint_short')}
|
|
192
|
+
</span>
|
|
193
|
+
)}
|
|
175
194
|
</span>
|
|
176
195
|
</div>
|
|
177
196
|
</div>
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
4
|
+
import {
|
|
5
|
+
createWalletCheckpointReconciliation,
|
|
6
|
+
listTransactionCategories,
|
|
7
|
+
} from '@tuturuuu/internal-api/finance';
|
|
8
|
+
import { FINANCE_DEFAULT_RECONCILIATION_CATEGORY_CONFIG_ID } from '@tuturuuu/internal-api/workspace-configs';
|
|
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 { useWorkspaceConfig } from '@tuturuuu/ui/hooks/use-workspace-config';
|
|
19
|
+
import { Input } from '@tuturuuu/ui/input';
|
|
20
|
+
import { Label } from '@tuturuuu/ui/label';
|
|
21
|
+
import {
|
|
22
|
+
Select,
|
|
23
|
+
SelectContent,
|
|
24
|
+
SelectItem,
|
|
25
|
+
SelectTrigger,
|
|
26
|
+
SelectValue,
|
|
27
|
+
} from '@tuturuuu/ui/select';
|
|
28
|
+
import { toast } from '@tuturuuu/ui/sonner';
|
|
29
|
+
import { Textarea } from '@tuturuuu/ui/textarea';
|
|
30
|
+
import { formatCurrency } from '@tuturuuu/utils/format';
|
|
31
|
+
import { useTranslations } from 'next-intl';
|
|
32
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
33
|
+
|
|
34
|
+
const NO_CATEGORY = 'none';
|
|
35
|
+
|
|
36
|
+
export function WalletCheckpointAdjustmentDialog({
|
|
37
|
+
checkedAt,
|
|
38
|
+
checkpointId,
|
|
39
|
+
currency,
|
|
40
|
+
onCreated,
|
|
41
|
+
onOpenChange,
|
|
42
|
+
open,
|
|
43
|
+
variance,
|
|
44
|
+
walletId,
|
|
45
|
+
walletName,
|
|
46
|
+
wsId,
|
|
47
|
+
}: {
|
|
48
|
+
checkedAt: string;
|
|
49
|
+
checkpointId: string;
|
|
50
|
+
currency: string;
|
|
51
|
+
onCreated: () => void;
|
|
52
|
+
onOpenChange: (open: boolean) => void;
|
|
53
|
+
open: boolean;
|
|
54
|
+
variance: number;
|
|
55
|
+
walletId: string;
|
|
56
|
+
walletName: string;
|
|
57
|
+
wsId: string;
|
|
58
|
+
}) {
|
|
59
|
+
const t = useTranslations('wallet-checkpoints');
|
|
60
|
+
const [categoryId, setCategoryId] = useState(NO_CATEGORY);
|
|
61
|
+
const [description, setDescription] = useState(
|
|
62
|
+
t('reconciliation_description', {
|
|
63
|
+
date: new Date(checkedAt).toLocaleDateString(),
|
|
64
|
+
wallet: walletName,
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
const [categoryInitialized, setCategoryInitialized] = useState(false);
|
|
68
|
+
const categoriesQuery = useQuery({
|
|
69
|
+
queryKey: ['transaction-categories', wsId],
|
|
70
|
+
queryFn: () => listTransactionCategories(wsId),
|
|
71
|
+
enabled: open,
|
|
72
|
+
});
|
|
73
|
+
const {
|
|
74
|
+
data: defaultReconciliationCategoryId,
|
|
75
|
+
isLoading: isLoadingDefaultReconciliationCategory,
|
|
76
|
+
} = useWorkspaceConfig<string>(
|
|
77
|
+
wsId,
|
|
78
|
+
FINANCE_DEFAULT_RECONCILIATION_CATEGORY_CONFIG_ID,
|
|
79
|
+
''
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!open) {
|
|
84
|
+
setCategoryId(NO_CATEGORY);
|
|
85
|
+
setCategoryInitialized(false);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
categoryInitialized ||
|
|
91
|
+
categoriesQuery.isLoading ||
|
|
92
|
+
isLoadingDefaultReconciliationCategory
|
|
93
|
+
) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const defaultCategoryExists = (categoriesQuery.data ?? []).some(
|
|
98
|
+
(category) => category.id === defaultReconciliationCategoryId
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
setCategoryId(
|
|
102
|
+
defaultReconciliationCategoryId && defaultCategoryExists
|
|
103
|
+
? defaultReconciliationCategoryId
|
|
104
|
+
: NO_CATEGORY
|
|
105
|
+
);
|
|
106
|
+
setCategoryInitialized(true);
|
|
107
|
+
}, [
|
|
108
|
+
categoriesQuery.data,
|
|
109
|
+
categoriesQuery.isLoading,
|
|
110
|
+
categoryInitialized,
|
|
111
|
+
defaultReconciliationCategoryId,
|
|
112
|
+
isLoadingDefaultReconciliationCategory,
|
|
113
|
+
open,
|
|
114
|
+
]);
|
|
115
|
+
const amountText = useMemo(
|
|
116
|
+
() =>
|
|
117
|
+
formatCurrency(variance, currency, undefined, {
|
|
118
|
+
maximumFractionDigits: currency === 'VND' ? 0 : 6,
|
|
119
|
+
signDisplay: 'always',
|
|
120
|
+
}),
|
|
121
|
+
[currency, variance]
|
|
122
|
+
);
|
|
123
|
+
const checkedAtText = useMemo(
|
|
124
|
+
() =>
|
|
125
|
+
new Intl.DateTimeFormat(undefined, {
|
|
126
|
+
dateStyle: 'medium',
|
|
127
|
+
timeStyle: 'short',
|
|
128
|
+
}).format(new Date(checkedAt)),
|
|
129
|
+
[checkedAt]
|
|
130
|
+
);
|
|
131
|
+
const mutation = useMutation({
|
|
132
|
+
mutationFn: () =>
|
|
133
|
+
createWalletCheckpointReconciliation(wsId, walletId, checkpointId, {
|
|
134
|
+
category_id: categoryId === NO_CATEGORY ? undefined : categoryId,
|
|
135
|
+
description,
|
|
136
|
+
}),
|
|
137
|
+
onSuccess: (result) => {
|
|
138
|
+
toast.success(
|
|
139
|
+
result.created ? t('reconciliation_created') : t('reconciliation_clean')
|
|
140
|
+
);
|
|
141
|
+
onCreated();
|
|
142
|
+
onOpenChange(false);
|
|
143
|
+
},
|
|
144
|
+
onError: (error) => {
|
|
145
|
+
toast.error(
|
|
146
|
+
error instanceof Error ? error.message : t('reconciliation_error')
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
153
|
+
<DialogContent>
|
|
154
|
+
<DialogHeader>
|
|
155
|
+
<DialogTitle>{t('create_reconciliation_transaction')}</DialogTitle>
|
|
156
|
+
<DialogDescription>
|
|
157
|
+
{t('create_reconciliation_description')}
|
|
158
|
+
</DialogDescription>
|
|
159
|
+
</DialogHeader>
|
|
160
|
+
<div className="grid gap-4 py-2">
|
|
161
|
+
<div className="grid gap-2">
|
|
162
|
+
<Label>{t('offset_amount_preview')}</Label>
|
|
163
|
+
<Input value={amountText} readOnly />
|
|
164
|
+
</div>
|
|
165
|
+
<div className="grid gap-2">
|
|
166
|
+
<Label htmlFor="checkpoint-reconciliation-date">{t('date')}</Label>
|
|
167
|
+
<Input
|
|
168
|
+
id="checkpoint-reconciliation-date"
|
|
169
|
+
value={checkedAtText}
|
|
170
|
+
readOnly
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="grid gap-2">
|
|
174
|
+
<Label>{t('category')}</Label>
|
|
175
|
+
<Select value={categoryId} onValueChange={setCategoryId}>
|
|
176
|
+
<SelectTrigger>
|
|
177
|
+
<SelectValue />
|
|
178
|
+
</SelectTrigger>
|
|
179
|
+
<SelectContent>
|
|
180
|
+
<SelectItem value={NO_CATEGORY}>{t('no_category')}</SelectItem>
|
|
181
|
+
{(categoriesQuery.data ?? [])
|
|
182
|
+
.filter(
|
|
183
|
+
(category): category is typeof category & { id: string } =>
|
|
184
|
+
typeof category.id === 'string'
|
|
185
|
+
)
|
|
186
|
+
.map((category) => (
|
|
187
|
+
<SelectItem key={category.id} value={category.id}>
|
|
188
|
+
{category.name}
|
|
189
|
+
</SelectItem>
|
|
190
|
+
))}
|
|
191
|
+
</SelectContent>
|
|
192
|
+
</Select>
|
|
193
|
+
</div>
|
|
194
|
+
<div className="grid gap-2">
|
|
195
|
+
<Label htmlFor="checkpoint-adjustment-description">
|
|
196
|
+
{t('description')}
|
|
197
|
+
</Label>
|
|
198
|
+
<Textarea
|
|
199
|
+
id="checkpoint-adjustment-description"
|
|
200
|
+
value={description}
|
|
201
|
+
onChange={(event) => setDescription(event.target.value)}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
<DialogFooter>
|
|
206
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
207
|
+
{t('cancel')}
|
|
208
|
+
</Button>
|
|
209
|
+
<Button
|
|
210
|
+
disabled={mutation.isPending || !categoryInitialized}
|
|
211
|
+
onClick={() => mutation.mutate()}
|
|
212
|
+
>
|
|
213
|
+
{mutation.isPending ? t('creating') : t('reconcile')}
|
|
214
|
+
</Button>
|
|
215
|
+
</DialogFooter>
|
|
216
|
+
</DialogContent>
|
|
217
|
+
</Dialog>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -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
|
+
}
|