@tuturuuu/ui 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +126 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +146 -0
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  22. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  23. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  24. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  25. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +219 -0
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  28. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  29. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  30. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  31. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +197 -0
  32. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  33. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +541 -0
  34. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +362 -0
  35. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  36. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  37. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  38. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  39. package/src/components/ui/finance/wallets/form.tsx +41 -197
  40. package/src/components/ui/finance/wallets/query-invalidation.ts +3 -0
  41. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  42. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  43. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  48. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +71 -5
  49. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +52 -35
  50. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  51. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  52. package/src/components/ui/finance/wallets/wallets-page.test.tsx +117 -36
  53. package/src/components/ui/finance/wallets/wallets-page.tsx +40 -64
  54. package/src/components/ui/storefront/accent-button.tsx +33 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  56. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  58. package/src/components/ui/storefront/image-panel.tsx +40 -0
  59. package/src/components/ui/storefront/index.ts +12 -0
  60. package/src/components/ui/storefront/listing-card.tsx +129 -0
  61. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  62. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  63. package/src/components/ui/storefront/types.ts +99 -0
  64. package/src/components/ui/storefront/utils.ts +90 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  67. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  68. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  69. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  70. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  71. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  72. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  73. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  74. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  75. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +124 -7
  76. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  77. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  79. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  80. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  81. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  83. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +268 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +243 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +41 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +157 -102
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  100. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +959 -340
  101. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  102. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  103. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  104. package/src/hooks/use-task-actions.ts +45 -0
  105. package/src/hooks/useBoardRealtime.ts +54 -1
  106. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  107. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -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 !== behavior) {
62
- onApplyRemoteBehavior(remoteBehavior);
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 { FinanceNumbersVisibilityToggle } from '@tuturuuu/ui/finance/shared/numbers-visibility-toggle';
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
- <div className="mb-4 flex justify-end">
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
- <DropdownMenuItem
95
- onClick={() => pushFinanceHref('/wallets?create=wallet')}
96
- >
97
- <Wallet className="mr-2 h-4 w-4" />
98
- <span>{t('new_wallet')}</span>
99
- </DropdownMenuItem>
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 [isTransfer, setIsTransfer] = useState(!!data?.transfer);
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: data?.description || '',
148
- amount: data?.amount ? Math.abs(data.amount) : undefined,
149
- origin_wallet_id: data?.wallet_id || '',
150
- destination_wallet_id: data?.transfer?.linked_wallet_id || '',
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
- : undefined,
154
- category_id: data?.category_id || '',
155
- taken_at: data?.taken_at ? new Date(data.taken_at) : new Date(),
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: !!data?.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 = data?.wallet_id || '';
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 = data?.category_id || '';
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
- categories.some((category) => category.id === lastSelections.categoryId)
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
- (contextualCategoryId &&
272
- categories.some((category) => category.id === contextualCategoryId)
273
- ? contextualCategoryId
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
- : contextualCategoryId && nextCategorySelection === contextualCategoryId
336
+ : contextualCategorySelection &&
337
+ nextCategorySelection === contextualCategorySelection
282
338
  ? t('transaction-data-table.prefill_source_current_context')
283
- : defaultCategoryId && nextCategorySelection === defaultCategoryId
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
- date: period.periodStart,
490
- label: generatePeriodLabel(period.periodStart, viewMode),
491
- transactions: period.transactions || [],
492
- // Store period stats for display
493
- periodStats: {
494
- totalIncome: period.totalIncome,
495
- totalExpense: period.totalExpense,
496
- netTotal: period.netTotal,
497
- transactionCount: period.transactionCount,
498
- hasRedactedAmounts: period.hasRedactedAmounts,
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
- allTransactions.forEach((transaction) => {
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
- allTransactions,
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
- : allTransactions.length === 0;
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={allTransactions}
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
- : allTransactions.length > 10) && (
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')}