@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
|
@@ -9,12 +9,14 @@ import { useEffect, useRef } from 'react';
|
|
|
9
9
|
type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
|
|
10
10
|
|
|
11
11
|
const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
|
|
12
|
+
const RECENT_LOCAL_BEHAVIOR_GRACE_MS = 5 * 60 * 1000;
|
|
12
13
|
|
|
13
14
|
const isValidBehavior = (value: string | undefined): value is SidebarBehavior =>
|
|
14
15
|
value === 'expanded' || value === 'collapsed' || value === 'hover';
|
|
15
16
|
|
|
16
17
|
interface SidebarRemoteBehaviorBridgeProps {
|
|
17
18
|
behavior: SidebarBehavior;
|
|
19
|
+
behaviorUpdatedAt: number | null;
|
|
18
20
|
localOverride: boolean;
|
|
19
21
|
localOverrideVersion: number;
|
|
20
22
|
onApplyRemoteBehavior: (newBehavior: SidebarBehavior) => void;
|
|
@@ -24,6 +26,7 @@ interface SidebarRemoteBehaviorBridgeProps {
|
|
|
24
26
|
|
|
25
27
|
export function SidebarRemoteBehaviorBridge({
|
|
26
28
|
behavior,
|
|
29
|
+
behaviorUpdatedAt,
|
|
27
30
|
localOverride,
|
|
28
31
|
localOverrideVersion,
|
|
29
32
|
onApplyRemoteBehavior,
|
|
@@ -39,6 +42,12 @@ export function SidebarRemoteBehaviorBridge({
|
|
|
39
42
|
const persistedUserChangeVersion = useRef(0);
|
|
40
43
|
const handledLocalOverrideVersion = useRef(0);
|
|
41
44
|
|
|
45
|
+
const hasRecentLocalBehavior =
|
|
46
|
+
typeof behaviorUpdatedAt === 'number' &&
|
|
47
|
+
Number.isFinite(behaviorUpdatedAt) &&
|
|
48
|
+
Date.now() - behaviorUpdatedAt >= 0 &&
|
|
49
|
+
Date.now() - behaviorUpdatedAt <= RECENT_LOCAL_BEHAVIOR_GRACE_MS;
|
|
50
|
+
|
|
42
51
|
useEffect(() => {
|
|
43
52
|
if (!remoteLoaded || !isValidBehavior(remoteBehavior)) return;
|
|
44
53
|
|
|
@@ -58,15 +67,25 @@ export function SidebarRemoteBehaviorBridge({
|
|
|
58
67
|
|
|
59
68
|
hasAppliedRemote.current = true;
|
|
60
69
|
|
|
61
|
-
if (remoteBehavior
|
|
62
|
-
|
|
70
|
+
if (remoteBehavior === behavior) return;
|
|
71
|
+
|
|
72
|
+
if (hasRecentLocalBehavior) {
|
|
73
|
+
updateConfig.mutate({
|
|
74
|
+
configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
|
|
75
|
+
value: behavior,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
63
78
|
}
|
|
79
|
+
|
|
80
|
+
onApplyRemoteBehavior(remoteBehavior);
|
|
64
81
|
}, [
|
|
65
82
|
behavior,
|
|
83
|
+
hasRecentLocalBehavior,
|
|
66
84
|
localOverride,
|
|
67
85
|
onApplyRemoteBehavior,
|
|
68
86
|
remoteBehavior,
|
|
69
87
|
remoteLoaded,
|
|
88
|
+
updateConfig,
|
|
70
89
|
userChangeVersion,
|
|
71
90
|
]);
|
|
72
91
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Navigation, type NavLink } from '@tuturuuu/ui/custom/navigation';
|
|
2
|
-
import {
|
|
2
|
+
import { FinanceLayoutControls } from '@tuturuuu/ui/finance/shared/finance-layout-controls';
|
|
3
3
|
import { QuickActions } from '@tuturuuu/ui/finance/shared/quick-actions';
|
|
4
4
|
import { getPermissions } from '@tuturuuu/utils/workspace-helper';
|
|
5
5
|
import { notFound, redirect } from 'next/navigation';
|
|
@@ -88,9 +88,7 @@ export default async function FinanceLayout({
|
|
|
88
88
|
return (
|
|
89
89
|
<>
|
|
90
90
|
<Navigation navLinks={navLinks} />
|
|
91
|
-
<
|
|
92
|
-
<FinanceNumbersVisibilityToggle />
|
|
93
|
-
</div>
|
|
91
|
+
<FinanceLayoutControls financePrefix={financePrefix} />
|
|
94
92
|
{children}
|
|
95
93
|
<QuickActions wsId={wsId} />
|
|
96
94
|
</>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from 'next-intl';
|
|
4
|
+
import { ToggleGroup, ToggleGroupItem } from '../../toggle-group';
|
|
5
|
+
import {
|
|
6
|
+
type FinanceBalanceMode,
|
|
7
|
+
useFinanceBalanceMode,
|
|
8
|
+
} from './use-finance-balance-mode';
|
|
9
|
+
|
|
10
|
+
export function FinanceBalanceModeToggle() {
|
|
11
|
+
const t = useTranslations('wallet-checkpoints');
|
|
12
|
+
const { mode, setMode } = useFinanceBalanceMode();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<ToggleGroup
|
|
16
|
+
type="single"
|
|
17
|
+
variant="outline"
|
|
18
|
+
size="sm"
|
|
19
|
+
value={mode}
|
|
20
|
+
onValueChange={(value) => {
|
|
21
|
+
if (value === 'ledger' || value === 'audited') {
|
|
22
|
+
setMode(value as FinanceBalanceMode);
|
|
23
|
+
}
|
|
24
|
+
}}
|
|
25
|
+
aria-label={t('balance_mode')}
|
|
26
|
+
>
|
|
27
|
+
<ToggleGroupItem value="ledger" aria-label={t('ledger_mode')}>
|
|
28
|
+
{t('ledger')}
|
|
29
|
+
</ToggleGroupItem>
|
|
30
|
+
<ToggleGroupItem value="audited" aria-label={t('audited_mode')}>
|
|
31
|
+
{t('audited')}
|
|
32
|
+
</ToggleGroupItem>
|
|
33
|
+
</ToggleGroup>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { FinanceBalanceModeToggle } from './balance-mode-toggle';
|
|
6
|
+
import { FinanceNumbersVisibilityToggle } from './numbers-visibility-toggle';
|
|
7
|
+
|
|
8
|
+
interface FinanceLayoutControlsProps {
|
|
9
|
+
className?: string;
|
|
10
|
+
financePrefix?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizePathname(pathname: string | null) {
|
|
14
|
+
if (!pathname) return '';
|
|
15
|
+
return pathname.replace(/\/+$/u, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isWalletIndexPath(pathname: string, financePrefix: string) {
|
|
19
|
+
const normalizedPrefix = financePrefix.replace(/\/+$/u, '');
|
|
20
|
+
if (normalizedPrefix) {
|
|
21
|
+
return pathname.endsWith(`${normalizedPrefix}/wallets`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return pathname.endsWith('/wallets');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function FinanceLayoutControls({
|
|
28
|
+
className,
|
|
29
|
+
financePrefix = '/finance',
|
|
30
|
+
}: FinanceLayoutControlsProps) {
|
|
31
|
+
const pathname = normalizePathname(usePathname());
|
|
32
|
+
|
|
33
|
+
if (isWalletIndexPath(pathname, financePrefix)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn('mb-4 flex flex-wrap justify-end gap-2', className)}>
|
|
39
|
+
<FinanceBalanceModeToggle />
|
|
40
|
+
<FinanceNumbersVisibilityToggle />
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -91,12 +91,20 @@ export function QuickActions({
|
|
|
91
91
|
</DropdownMenuItem>
|
|
92
92
|
)}
|
|
93
93
|
{canCreateWallets && (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
<>
|
|
95
|
+
<DropdownMenuItem
|
|
96
|
+
onClick={() => pushFinanceHref('/wallets?create=wallet')}
|
|
97
|
+
>
|
|
98
|
+
<Wallet className="mr-2 h-4 w-4" />
|
|
99
|
+
<span>{t('new_wallet')}</span>
|
|
100
|
+
</DropdownMenuItem>
|
|
101
|
+
<DropdownMenuItem
|
|
102
|
+
onClick={() => pushFinanceHref('/wallets?create=credit-card')}
|
|
103
|
+
>
|
|
104
|
+
<CreditCard className="mr-2 h-4 w-4" />
|
|
105
|
+
<span>{t('new_credit_card')}</span>
|
|
106
|
+
</DropdownMenuItem>
|
|
107
|
+
</>
|
|
100
108
|
)}
|
|
101
109
|
{canManageFinance && (
|
|
102
110
|
<DropdownMenuItem
|
|
@@ -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,
|
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
import { PeriodBreakdownPanel } from './period-charts';
|
|
74
74
|
import { TransactionCard } from './transaction-card';
|
|
75
75
|
import { TransactionStatistics } from './transaction-statistics';
|
|
76
|
+
import { mergeLinkedTransferTransactions } from './transfer-merge';
|
|
76
77
|
|
|
77
78
|
interface InfiniteTransactionsListProps {
|
|
78
79
|
wsId: string;
|
|
@@ -446,6 +447,10 @@ export function InfiniteTransactionsList({
|
|
|
446
447
|
const allTransactions = usePeriods
|
|
447
448
|
? []
|
|
448
449
|
: dailyData?.pages.flatMap((page) => page.data) || [];
|
|
450
|
+
const visibleTransactions = useMemo(
|
|
451
|
+
() => mergeLinkedTransferTransactions(allTransactions),
|
|
452
|
+
[allTransactions]
|
|
453
|
+
);
|
|
449
454
|
|
|
450
455
|
// All periods for period-based views
|
|
451
456
|
const allPeriods: TransactionPeriod[] = usePeriods
|
|
@@ -485,26 +490,32 @@ export function InfiniteTransactionsList({
|
|
|
485
490
|
const groupedTransactions = useMemo(() => {
|
|
486
491
|
// For period-based views, convert periods to grouped transactions format
|
|
487
492
|
if (usePeriods) {
|
|
488
|
-
return allPeriods.map((period) =>
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
493
|
+
return allPeriods.map((period) => {
|
|
494
|
+
const transactions = mergeLinkedTransferTransactions(
|
|
495
|
+
period.transactions || []
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
date: period.periodStart,
|
|
500
|
+
label: generatePeriodLabel(period.periodStart, viewMode),
|
|
501
|
+
transactions,
|
|
502
|
+
// Store period stats for display
|
|
503
|
+
periodStats: {
|
|
504
|
+
totalIncome: period.totalIncome,
|
|
505
|
+
totalExpense: period.totalExpense,
|
|
506
|
+
netTotal: period.netTotal,
|
|
507
|
+
transactionCount: transactions.length,
|
|
508
|
+
hasRedactedAmounts: period.hasRedactedAmounts,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
});
|
|
501
512
|
}
|
|
502
513
|
|
|
503
514
|
// For daily view, group transactions by date in the user's timezone
|
|
504
515
|
const groups: GroupedTransactions[] = [];
|
|
505
516
|
const now = dayjs().tz(resolvedTimezone);
|
|
506
517
|
|
|
507
|
-
|
|
518
|
+
visibleTransactions.forEach((transaction) => {
|
|
508
519
|
// Parse transaction date in the user's timezone
|
|
509
520
|
const transactionDate = dayjs(transaction.taken_at).tz(resolvedTimezone);
|
|
510
521
|
const dateKey = transactionDate.format('YYYY-MM-DD');
|
|
@@ -543,7 +554,7 @@ export function InfiniteTransactionsList({
|
|
|
543
554
|
|
|
544
555
|
return groups;
|
|
545
556
|
}, [
|
|
546
|
-
|
|
557
|
+
visibleTransactions,
|
|
547
558
|
allPeriods,
|
|
548
559
|
usePeriods,
|
|
549
560
|
viewMode,
|
|
@@ -604,7 +615,7 @@ export function InfiniteTransactionsList({
|
|
|
604
615
|
// Check if there's no data (either no transactions for daily or no periods for other views)
|
|
605
616
|
const hasNoData = usePeriods
|
|
606
617
|
? allPeriods.length === 0
|
|
607
|
-
:
|
|
618
|
+
: visibleTransactions.length === 0;
|
|
608
619
|
|
|
609
620
|
if (hasNoData) {
|
|
610
621
|
return (
|
|
@@ -627,7 +638,7 @@ export function InfiniteTransactionsList({
|
|
|
627
638
|
{/* Statistics Summary - Only show when filters are active (daily view only uses transactions for stats) */}
|
|
628
639
|
{hasActiveFilter && (stats || isStatsLoading) && !usePeriods && (
|
|
629
640
|
<TransactionStatistics
|
|
630
|
-
transactions={
|
|
641
|
+
transactions={visibleTransactions}
|
|
631
642
|
stats={stats}
|
|
632
643
|
isLoading={isStatsLoading}
|
|
633
644
|
currency={currency}
|
|
@@ -1023,7 +1034,7 @@ export function InfiniteTransactionsList({
|
|
|
1023
1034
|
{!hasNextPage &&
|
|
1024
1035
|
(usePeriods
|
|
1025
1036
|
? allPeriods.length > 5
|
|
1026
|
-
:
|
|
1037
|
+
: visibleTransactions.length > 10) && (
|
|
1027
1038
|
<div className="rounded-xl border border-dashed bg-muted/20 p-6 text-center">
|
|
1028
1039
|
<p className="text-muted-foreground text-sm">
|
|
1029
1040
|
{t('user-data-table.common.end_of_list')}
|