@tuturuuu/ui 0.5.0 → 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 +29 -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 +50 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
- 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/wallet-filter.tsx +21 -2
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
- 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 +2 -1
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
- 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 +1 -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 +64 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -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 +111 -37
- package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
- 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/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/use-my-tasks-state.ts +2 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -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 +128 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
- 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-name-input.test.tsx +17 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
- 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/task-dialog-actions.tsx +5 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
- package/src/hooks/useBoardRealtime.ts +54 -1
- package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
- package/src/hooks/useTaskUserRealtime.ts +338 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
const BALANCE_MODE_COOKIE_NAME = 'finance-balance-mode';
|
|
6
|
+
const BALANCE_MODE_CHANGE_EVENT = 'finance-balance-mode-change';
|
|
7
|
+
|
|
8
|
+
export type FinanceBalanceMode = 'audited' | 'ledger';
|
|
9
|
+
|
|
10
|
+
const isBalanceMode = (value: string | null): value is FinanceBalanceMode =>
|
|
11
|
+
value === 'audited' || value === 'ledger';
|
|
12
|
+
|
|
13
|
+
const getCookie = (name: string): string | null => {
|
|
14
|
+
if (typeof document === 'undefined') return null;
|
|
15
|
+
const nameEQ = `${name}=`;
|
|
16
|
+
const cookies = document.cookie.split(';');
|
|
17
|
+
for (let i = 0; i < cookies.length; i += 1) {
|
|
18
|
+
let cookie = cookies[i];
|
|
19
|
+
if (!cookie) continue;
|
|
20
|
+
while (cookie.charAt(0) === ' ') cookie = cookie.substring(1);
|
|
21
|
+
if (cookie.indexOf(nameEQ) === 0) {
|
|
22
|
+
return cookie.substring(nameEQ.length);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const setCookie = (name: string, value: string, days = 365) => {
|
|
29
|
+
const expires = new Date();
|
|
30
|
+
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
|
31
|
+
// biome-ignore lint/suspicious/noDocumentCookie: Used for finance balance mode persistence.
|
|
32
|
+
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function useFinanceBalanceMode() {
|
|
36
|
+
const [mode, setModeState] = useState<FinanceBalanceMode>('ledger');
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const saved = getCookie(BALANCE_MODE_COOKIE_NAME);
|
|
40
|
+
if (isBalanceMode(saved)) {
|
|
41
|
+
setModeState(saved);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const handleBalanceModeChange = () => {
|
|
45
|
+
const nextValue = getCookie(BALANCE_MODE_COOKIE_NAME);
|
|
46
|
+
if (isBalanceMode(nextValue)) {
|
|
47
|
+
setModeState(nextValue);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
window.addEventListener(BALANCE_MODE_CHANGE_EVENT, handleBalanceModeChange);
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
window.removeEventListener(
|
|
55
|
+
BALANCE_MODE_CHANGE_EVENT,
|
|
56
|
+
handleBalanceModeChange
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const setMode = useCallback((nextMode: FinanceBalanceMode) => {
|
|
62
|
+
setModeState(nextMode);
|
|
63
|
+
setCookie(BALANCE_MODE_COOKIE_NAME, nextMode);
|
|
64
|
+
window.dispatchEvent(new Event(BALANCE_MODE_CHANGE_EVENT));
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
isAuditedMode: mode === 'audited',
|
|
69
|
+
mode,
|
|
70
|
+
setMode,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getWalletBalanceTone,
|
|
4
|
+
resolveWalletBalanceForMode,
|
|
5
|
+
} from './wallet-balance-mode';
|
|
6
|
+
|
|
7
|
+
describe('wallet balance mode resolver', () => {
|
|
8
|
+
it('uses ledger balance in ledger mode', () => {
|
|
9
|
+
expect(
|
|
10
|
+
resolveWalletBalanceForMode(
|
|
11
|
+
{
|
|
12
|
+
audit_balance: 150,
|
|
13
|
+
audit_status: 'unresolved',
|
|
14
|
+
balance: 100,
|
|
15
|
+
},
|
|
16
|
+
'ledger'
|
|
17
|
+
)
|
|
18
|
+
).toMatchObject({
|
|
19
|
+
contextBalance: 150,
|
|
20
|
+
displayBalance: 100,
|
|
21
|
+
hasAuditedBalance: true,
|
|
22
|
+
usesAuditedBalance: false,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('uses audited balance in audited mode when a checkpoint exists', () => {
|
|
27
|
+
expect(
|
|
28
|
+
resolveWalletBalanceForMode(
|
|
29
|
+
{
|
|
30
|
+
audit_balance: -25,
|
|
31
|
+
audit_status: 'unresolved',
|
|
32
|
+
balance: 100,
|
|
33
|
+
},
|
|
34
|
+
'audited'
|
|
35
|
+
)
|
|
36
|
+
).toMatchObject({
|
|
37
|
+
contextBalance: 100,
|
|
38
|
+
displayBalance: -25,
|
|
39
|
+
hasAuditedBalance: true,
|
|
40
|
+
usesAuditedBalance: true,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('falls back to ledger balance in audited mode without a checkpoint', () => {
|
|
45
|
+
expect(
|
|
46
|
+
resolveWalletBalanceForMode(
|
|
47
|
+
{
|
|
48
|
+
audit_status: 'no_checkpoint',
|
|
49
|
+
balance: 100,
|
|
50
|
+
},
|
|
51
|
+
'audited'
|
|
52
|
+
)
|
|
53
|
+
).toMatchObject({
|
|
54
|
+
contextBalance: null,
|
|
55
|
+
displayBalance: 100,
|
|
56
|
+
hasAuditedBalance: false,
|
|
57
|
+
usesAuditedBalance: false,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('derives tone from the effective balance', () => {
|
|
62
|
+
expect(getWalletBalanceTone(10)).toBe('positive');
|
|
63
|
+
expect(getWalletBalanceTone(-10)).toBe('negative');
|
|
64
|
+
expect(getWalletBalanceTone(0)).toBe('neutral');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { FinanceBalanceMode } from './use-finance-balance-mode';
|
|
2
|
+
|
|
3
|
+
type WalletBalanceInput = {
|
|
4
|
+
audit_balance?: number | null;
|
|
5
|
+
audit_status?: 'clean' | 'no_checkpoint' | 'unresolved' | null;
|
|
6
|
+
audit_variance?: number | null;
|
|
7
|
+
balance?: number | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function toFiniteNumber(value: number | null | undefined) {
|
|
11
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveWalletBalanceForMode(
|
|
15
|
+
wallet: WalletBalanceInput,
|
|
16
|
+
mode: FinanceBalanceMode
|
|
17
|
+
) {
|
|
18
|
+
const ledgerBalance = toFiniteNumber(wallet.balance) ?? 0;
|
|
19
|
+
const auditedBalance = toFiniteNumber(wallet.audit_balance);
|
|
20
|
+
const hasAuditedBalance = auditedBalance !== null;
|
|
21
|
+
const usesAuditedBalance = mode === 'audited' && hasAuditedBalance;
|
|
22
|
+
const displayBalance = usesAuditedBalance ? auditedBalance : ledgerBalance;
|
|
23
|
+
const contextBalance = usesAuditedBalance ? ledgerBalance : auditedBalance;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
auditedBalance,
|
|
27
|
+
auditStatus: wallet.audit_status ?? null,
|
|
28
|
+
auditVariance: toFiniteNumber(wallet.audit_variance),
|
|
29
|
+
contextBalance,
|
|
30
|
+
displayBalance,
|
|
31
|
+
hasAuditedBalance,
|
|
32
|
+
isAuditedMode: mode === 'audited',
|
|
33
|
+
ledgerBalance,
|
|
34
|
+
usesAuditedBalance,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getWalletBalanceTone(balance: number) {
|
|
39
|
+
if (balance > 0) return 'positive';
|
|
40
|
+
if (balance < 0) return 'negative';
|
|
41
|
+
return 'neutral';
|
|
42
|
+
}
|
|
@@ -23,6 +23,26 @@ export type TransactionCategory = DbTransactionCategory;
|
|
|
23
23
|
export type Wallet = DbWallet;
|
|
24
24
|
export type TagDraft = Pick<DbTag, 'name'> & Partial<Pick<DbTag, 'color'>>;
|
|
25
25
|
|
|
26
|
+
export type TransactionFormInitialMode = 'transaction' | 'transfer';
|
|
27
|
+
|
|
28
|
+
export interface TransactionFormInitialTransaction {
|
|
29
|
+
amount?: number;
|
|
30
|
+
category_id?: string;
|
|
31
|
+
categoryKind?: 'expense' | 'income';
|
|
32
|
+
description?: string;
|
|
33
|
+
origin_wallet_id?: string;
|
|
34
|
+
taken_at?: Date;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TransactionFormInitialTransfer {
|
|
38
|
+
amount?: number;
|
|
39
|
+
description?: string;
|
|
40
|
+
destination_amount?: number;
|
|
41
|
+
destination_wallet_id?: string;
|
|
42
|
+
origin_wallet_id?: string;
|
|
43
|
+
taken_at?: Date;
|
|
44
|
+
}
|
|
45
|
+
|
|
26
46
|
export interface TransactionFormProps {
|
|
27
47
|
wsId: string;
|
|
28
48
|
data?: Partial<Transaction>;
|
|
@@ -33,6 +53,9 @@ export interface TransactionFormProps {
|
|
|
33
53
|
canUpdateConfidentialTransactions?: boolean;
|
|
34
54
|
canChangeFinanceWallets?: boolean;
|
|
35
55
|
canSetFinanceWalletsOnCreate?: boolean;
|
|
56
|
+
initialMode?: TransactionFormInitialMode;
|
|
57
|
+
initialTransaction?: TransactionFormInitialTransaction;
|
|
58
|
+
initialTransfer?: TransactionFormInitialTransfer;
|
|
36
59
|
permissionRequestUser?: FinancePermissionRequestUser | null;
|
|
37
60
|
}
|
|
38
61
|
|
|
@@ -73,6 +73,9 @@ export function TransactionForm({
|
|
|
73
73
|
canUpdateConfidentialTransactions,
|
|
74
74
|
canChangeFinanceWallets = true,
|
|
75
75
|
canSetFinanceWalletsOnCreate = true,
|
|
76
|
+
initialMode = 'transaction',
|
|
77
|
+
initialTransaction,
|
|
78
|
+
initialTransfer,
|
|
76
79
|
permissionRequestUser,
|
|
77
80
|
}: TransactionFormProps) {
|
|
78
81
|
const t = useTranslations();
|
|
@@ -83,7 +86,8 @@ export function TransactionForm({
|
|
|
83
86
|
const [attachments, setAttachments] = useState<TransactionAttachmentDraft[]>(
|
|
84
87
|
[]
|
|
85
88
|
);
|
|
86
|
-
const
|
|
89
|
+
const initialIsTransfer = initialMode === 'transfer' || !!data?.transfer;
|
|
90
|
+
const [isTransfer, setIsTransfer] = useState(initialIsTransfer);
|
|
87
91
|
// Start in override mode when editing an existing transfer (preserve stored amounts).
|
|
88
92
|
// Start in auto mode for new transfers so the exchange rate pre-fills destination.
|
|
89
93
|
const [isDestinationOverridden, setIsDestinationOverridden] = useState(
|
|
@@ -144,18 +148,39 @@ export function TransactionForm({
|
|
|
144
148
|
resolver: zodResolver(TransactionFormSchema),
|
|
145
149
|
defaultValues: {
|
|
146
150
|
id: data?.id,
|
|
147
|
-
description:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
description:
|
|
152
|
+
data?.description ||
|
|
153
|
+
(initialIsTransfer
|
|
154
|
+
? initialTransfer?.description
|
|
155
|
+
: initialTransaction?.description) ||
|
|
156
|
+
'',
|
|
157
|
+
amount: data?.amount
|
|
158
|
+
? Math.abs(data.amount)
|
|
159
|
+
: initialIsTransfer
|
|
160
|
+
? initialTransfer?.amount
|
|
161
|
+
: initialTransaction?.amount,
|
|
162
|
+
origin_wallet_id:
|
|
163
|
+
data?.wallet_id ||
|
|
164
|
+
(initialIsTransfer
|
|
165
|
+
? initialTransfer?.origin_wallet_id
|
|
166
|
+
: initialTransaction?.origin_wallet_id) ||
|
|
167
|
+
'',
|
|
168
|
+
destination_wallet_id:
|
|
169
|
+
data?.transfer?.linked_wallet_id ||
|
|
170
|
+
initialTransfer?.destination_wallet_id ||
|
|
171
|
+
'',
|
|
151
172
|
destination_amount: data?.transfer?.linked_amount
|
|
152
173
|
? Math.abs(data.transfer.linked_amount)
|
|
153
|
-
:
|
|
154
|
-
category_id: data?.category_id || '',
|
|
155
|
-
taken_at: data?.taken_at
|
|
174
|
+
: initialTransfer?.destination_amount,
|
|
175
|
+
category_id: data?.category_id || initialTransaction?.category_id || '',
|
|
176
|
+
taken_at: data?.taken_at
|
|
177
|
+
? new Date(data.taken_at)
|
|
178
|
+
: initialIsTransfer
|
|
179
|
+
? (initialTransfer?.taken_at ?? new Date())
|
|
180
|
+
: (initialTransaction?.taken_at ?? new Date()),
|
|
156
181
|
report_opt_in: data?.report_opt_in ?? true,
|
|
157
182
|
tag_ids: [] as string[],
|
|
158
|
-
is_transfer:
|
|
183
|
+
is_transfer: initialIsTransfer,
|
|
159
184
|
is_amount_confidential:
|
|
160
185
|
(data as Record<string, unknown>)?.is_amount_confidential === true,
|
|
161
186
|
is_description_confidential:
|
|
@@ -189,7 +214,12 @@ export function TransactionForm({
|
|
|
189
214
|
const isUserEdited =
|
|
190
215
|
originWalletState.isDirty || originWalletState.isTouched;
|
|
191
216
|
const currentWalletId = form.getValues('origin_wallet_id');
|
|
192
|
-
const contextualWalletId =
|
|
217
|
+
const contextualWalletId =
|
|
218
|
+
data?.wallet_id ||
|
|
219
|
+
(initialIsTransfer
|
|
220
|
+
? initialTransfer?.origin_wallet_id
|
|
221
|
+
: initialTransaction?.origin_wallet_id) ||
|
|
222
|
+
'';
|
|
193
223
|
|
|
194
224
|
if (data?.id || isUserEdited) return;
|
|
195
225
|
if (!wallets || wallets.length === 0) return;
|
|
@@ -241,6 +271,9 @@ export function TransactionForm({
|
|
|
241
271
|
data?.wallet_id,
|
|
242
272
|
defaultWalletId,
|
|
243
273
|
form,
|
|
274
|
+
initialIsTransfer,
|
|
275
|
+
initialTransaction?.origin_wallet_id,
|
|
276
|
+
initialTransfer?.origin_wallet_id,
|
|
244
277
|
isLastSelectionsInitialized,
|
|
245
278
|
isLoadingRememberLastSelections,
|
|
246
279
|
lastSelections.walletId,
|
|
@@ -253,7 +286,22 @@ export function TransactionForm({
|
|
|
253
286
|
const categoryState = form.getFieldState('category_id');
|
|
254
287
|
const isUserEdited = categoryState.isDirty || categoryState.isTouched;
|
|
255
288
|
const currentCategoryId = form.getValues('category_id');
|
|
256
|
-
const contextualCategoryId =
|
|
289
|
+
const contextualCategoryId =
|
|
290
|
+
data?.category_id || initialTransaction?.category_id || '';
|
|
291
|
+
const categoryKind = initialTransaction?.categoryKind;
|
|
292
|
+
const matchesCategoryKind = (category: { is_expense?: boolean | null }) => {
|
|
293
|
+
if (!categoryKind) return true;
|
|
294
|
+
return categoryKind === 'income'
|
|
295
|
+
? category.is_expense === false
|
|
296
|
+
: category.is_expense !== false;
|
|
297
|
+
};
|
|
298
|
+
const hasSelectableCategory = (categoryId?: string | null) =>
|
|
299
|
+
!!categoryId &&
|
|
300
|
+
(categories ?? []).some(
|
|
301
|
+
(category) =>
|
|
302
|
+
category.id === categoryId &&
|
|
303
|
+
(!categoryKind || matchesCategoryKind(category))
|
|
304
|
+
);
|
|
257
305
|
|
|
258
306
|
if (data?.id || isUserEdited) return;
|
|
259
307
|
if (!categories || categories.length === 0) return;
|
|
@@ -263,24 +311,33 @@ export function TransactionForm({
|
|
|
263
311
|
const rememberedCategoryId =
|
|
264
312
|
rememberLastSelections &&
|
|
265
313
|
lastSelections.categoryId &&
|
|
266
|
-
|
|
314
|
+
hasSelectableCategory(lastSelections.categoryId)
|
|
267
315
|
? lastSelections.categoryId
|
|
268
316
|
: '';
|
|
317
|
+
const contextualCategorySelection = hasSelectableCategory(
|
|
318
|
+
contextualCategoryId
|
|
319
|
+
)
|
|
320
|
+
? contextualCategoryId
|
|
321
|
+
: '';
|
|
322
|
+
const defaultCategorySelection = hasSelectableCategory(defaultCategoryId)
|
|
323
|
+
? defaultCategoryId
|
|
324
|
+
: '';
|
|
325
|
+
const intentCategorySelection =
|
|
326
|
+
categoryKind && categories.find(matchesCategoryKind)?.id
|
|
327
|
+
? categories.find(matchesCategoryKind)?.id
|
|
328
|
+
: '';
|
|
269
329
|
const nextCategorySelection =
|
|
270
330
|
rememberedCategoryId ||
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
: '') ||
|
|
275
|
-
(defaultCategoryId &&
|
|
276
|
-
categories.some((category) => category.id === defaultCategoryId)
|
|
277
|
-
? defaultCategoryId
|
|
278
|
-
: '');
|
|
331
|
+
contextualCategorySelection ||
|
|
332
|
+
defaultCategorySelection ||
|
|
333
|
+
intentCategorySelection;
|
|
279
334
|
const sourceLabel = rememberedCategoryId
|
|
280
335
|
? t('transaction-data-table.prefill_source_last_used')
|
|
281
|
-
:
|
|
336
|
+
: contextualCategorySelection &&
|
|
337
|
+
nextCategorySelection === contextualCategorySelection
|
|
282
338
|
? t('transaction-data-table.prefill_source_current_context')
|
|
283
|
-
:
|
|
339
|
+
: defaultCategorySelection &&
|
|
340
|
+
nextCategorySelection === defaultCategorySelection
|
|
284
341
|
? t('transaction-data-table.prefill_source_workspace_default')
|
|
285
342
|
: '';
|
|
286
343
|
|
|
@@ -304,6 +361,8 @@ export function TransactionForm({
|
|
|
304
361
|
data?.id,
|
|
305
362
|
defaultCategoryId,
|
|
306
363
|
form,
|
|
364
|
+
initialTransaction?.categoryKind,
|
|
365
|
+
initialTransaction?.category_id,
|
|
307
366
|
isLastSelectionsInitialized,
|
|
308
367
|
isLoadingRememberLastSelections,
|
|
309
368
|
lastSelections.categoryId,
|
|
@@ -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>
|
package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
createWalletCheckpointReconciliation,
|
|
6
6
|
listTransactionCategories,
|
|
7
7
|
} from '@tuturuuu/internal-api/finance';
|
|
8
|
+
import { FINANCE_DEFAULT_RECONCILIATION_CATEGORY_CONFIG_ID } from '@tuturuuu/internal-api/workspace-configs';
|
|
8
9
|
import { Button } from '@tuturuuu/ui/button';
|
|
9
10
|
import {
|
|
10
11
|
Dialog,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
DialogHeader,
|
|
15
16
|
DialogTitle,
|
|
16
17
|
} from '@tuturuuu/ui/dialog';
|
|
18
|
+
import { useWorkspaceConfig } from '@tuturuuu/ui/hooks/use-workspace-config';
|
|
17
19
|
import { Input } from '@tuturuuu/ui/input';
|
|
18
20
|
import { Label } from '@tuturuuu/ui/label';
|
|
19
21
|
import {
|
|
@@ -27,12 +29,13 @@ import { toast } from '@tuturuuu/ui/sonner';
|
|
|
27
29
|
import { Textarea } from '@tuturuuu/ui/textarea';
|
|
28
30
|
import { formatCurrency } from '@tuturuuu/utils/format';
|
|
29
31
|
import { useTranslations } from 'next-intl';
|
|
30
|
-
import { useMemo, useState } from 'react';
|
|
32
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
31
33
|
|
|
32
34
|
const NO_CATEGORY = 'none';
|
|
33
35
|
|
|
34
36
|
export function WalletCheckpointAdjustmentDialog({
|
|
35
37
|
checkedAt,
|
|
38
|
+
checkpointId,
|
|
36
39
|
currency,
|
|
37
40
|
onCreated,
|
|
38
41
|
onOpenChange,
|
|
@@ -43,6 +46,7 @@ export function WalletCheckpointAdjustmentDialog({
|
|
|
43
46
|
wsId,
|
|
44
47
|
}: {
|
|
45
48
|
checkedAt: string;
|
|
49
|
+
checkpointId: string;
|
|
46
50
|
currency: string;
|
|
47
51
|
onCreated: () => void;
|
|
48
52
|
onOpenChange: (open: boolean) => void;
|
|
@@ -55,21 +59,59 @@ export function WalletCheckpointAdjustmentDialog({
|
|
|
55
59
|
const t = useTranslations('wallet-checkpoints');
|
|
56
60
|
const [categoryId, setCategoryId] = useState(NO_CATEGORY);
|
|
57
61
|
const [description, setDescription] = useState(
|
|
58
|
-
t('
|
|
62
|
+
t('reconciliation_description', {
|
|
59
63
|
date: new Date(checkedAt).toLocaleDateString(),
|
|
60
64
|
wallet: walletName,
|
|
61
65
|
})
|
|
62
66
|
);
|
|
63
|
-
const [
|
|
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
|
-
});
|
|
67
|
+
const [categoryInitialized, setCategoryInitialized] = useState(false);
|
|
68
68
|
const categoriesQuery = useQuery({
|
|
69
69
|
queryKey: ['transaction-categories', wsId],
|
|
70
70
|
queryFn: () => listTransactionCategories(wsId),
|
|
71
71
|
enabled: open,
|
|
72
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
|
+
]);
|
|
73
115
|
const amountText = useMemo(
|
|
74
116
|
() =>
|
|
75
117
|
formatCurrency(variance, currency, undefined, {
|
|
@@ -78,24 +120,30 @@ export function WalletCheckpointAdjustmentDialog({
|
|
|
78
120
|
}),
|
|
79
121
|
[currency, variance]
|
|
80
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
|
+
);
|
|
81
131
|
const mutation = useMutation({
|
|
82
132
|
mutationFn: () =>
|
|
83
|
-
|
|
84
|
-
amount: variance,
|
|
133
|
+
createWalletCheckpointReconciliation(wsId, walletId, checkpointId, {
|
|
85
134
|
category_id: categoryId === NO_CATEGORY ? undefined : categoryId,
|
|
86
135
|
description,
|
|
87
|
-
origin_wallet_id: walletId,
|
|
88
|
-
report_opt_in: false,
|
|
89
|
-
taken_at: new Date(takenAt).toISOString(),
|
|
90
136
|
}),
|
|
91
|
-
onSuccess: () => {
|
|
92
|
-
toast.success(
|
|
137
|
+
onSuccess: (result) => {
|
|
138
|
+
toast.success(
|
|
139
|
+
result.created ? t('reconciliation_created') : t('reconciliation_clean')
|
|
140
|
+
);
|
|
93
141
|
onCreated();
|
|
94
142
|
onOpenChange(false);
|
|
95
143
|
},
|
|
96
144
|
onError: (error) => {
|
|
97
145
|
toast.error(
|
|
98
|
-
error instanceof Error ? error.message : t('
|
|
146
|
+
error instanceof Error ? error.message : t('reconciliation_error')
|
|
99
147
|
);
|
|
100
148
|
},
|
|
101
149
|
});
|
|
@@ -104,23 +152,22 @@ export function WalletCheckpointAdjustmentDialog({
|
|
|
104
152
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
105
153
|
<DialogContent>
|
|
106
154
|
<DialogHeader>
|
|
107
|
-
<DialogTitle>{t('
|
|
155
|
+
<DialogTitle>{t('create_reconciliation_transaction')}</DialogTitle>
|
|
108
156
|
<DialogDescription>
|
|
109
|
-
{t('
|
|
157
|
+
{t('create_reconciliation_description')}
|
|
110
158
|
</DialogDescription>
|
|
111
159
|
</DialogHeader>
|
|
112
160
|
<div className="grid gap-4 py-2">
|
|
113
161
|
<div className="grid gap-2">
|
|
114
|
-
<Label>{t('
|
|
162
|
+
<Label>{t('offset_amount_preview')}</Label>
|
|
115
163
|
<Input value={amountText} readOnly />
|
|
116
164
|
</div>
|
|
117
165
|
<div className="grid gap-2">
|
|
118
|
-
<Label htmlFor="checkpoint-
|
|
166
|
+
<Label htmlFor="checkpoint-reconciliation-date">{t('date')}</Label>
|
|
119
167
|
<Input
|
|
120
|
-
id="checkpoint-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
onChange={(event) => setTakenAt(event.target.value)}
|
|
168
|
+
id="checkpoint-reconciliation-date"
|
|
169
|
+
value={checkedAtText}
|
|
170
|
+
readOnly
|
|
124
171
|
/>
|
|
125
172
|
</div>
|
|
126
173
|
<div className="grid gap-2">
|
|
@@ -160,10 +207,10 @@ export function WalletCheckpointAdjustmentDialog({
|
|
|
160
207
|
{t('cancel')}
|
|
161
208
|
</Button>
|
|
162
209
|
<Button
|
|
163
|
-
disabled={mutation.isPending ||
|
|
210
|
+
disabled={mutation.isPending || !categoryInitialized}
|
|
164
211
|
onClick={() => mutation.mutate()}
|
|
165
212
|
>
|
|
166
|
-
{mutation.isPending ? t('creating') : t('
|
|
213
|
+
{mutation.isPending ? t('creating') : t('reconcile')}
|
|
167
214
|
</Button>
|
|
168
215
|
</DialogFooter>
|
|
169
216
|
</DialogContent>
|