@tuturuuu/ui 0.6.2 → 0.8.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 (108) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/biome.json +1 -1
  3. package/package.json +11 -11
  4. package/src/components/ui/calendar-app/components/calendar-connections.tsx +17 -13
  5. package/src/components/ui/calendar-app/components/connected-accounts-dialog.tsx +2 -5
  6. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +2 -5
  7. package/src/components/ui/calendar.test.tsx +24 -0
  8. package/src/components/ui/calendar.tsx +1 -0
  9. package/src/components/ui/currency-input.test.tsx +43 -0
  10. package/src/components/ui/currency-input.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  12. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  13. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  14. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  15. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  16. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  17. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  18. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  19. package/src/components/ui/date-time-picker.tsx +352 -234
  20. package/src/components/ui/finance/categories-tags-tabs.tsx +23 -1
  21. package/src/components/ui/finance/command/finance-command-actions.test.tsx +48 -0
  22. package/src/components/ui/finance/command/finance-command-actions.tsx +200 -0
  23. package/src/components/ui/finance/command/finance-command-provider.test.tsx +151 -0
  24. package/src/components/ui/finance/command/finance-command-provider.tsx +250 -0
  25. package/src/components/ui/finance/command/finance-command-results.tsx +262 -0
  26. package/src/components/ui/finance/invoices/pending-invoices-table.tsx +22 -9
  27. package/src/components/ui/finance/shared/quick-actions.tsx +39 -90
  28. package/src/components/ui/finance/tags/tag-manager.tsx +24 -5
  29. package/src/components/ui/finance/transactions/form-basic-tab.tsx +33 -49
  30. package/src/components/ui/finance/transactions/form-types.ts +5 -0
  31. package/src/components/ui/finance/transactions/form.test.tsx +105 -22
  32. package/src/components/ui/finance/transactions/form.tsx +116 -20
  33. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +13 -6
  34. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  35. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +25 -1
  36. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +16 -3
  37. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +3 -0
  38. package/src/components/ui/finance/transactions/transactionId/transaction-details-page.tsx +3 -0
  39. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +6 -0
  40. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +20 -2
  41. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -0
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +7 -2
  43. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +7 -2
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +38 -1
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +5 -0
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +18 -2
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +3 -0
  48. package/src/components/ui/finance/wallets/wallets-page.tsx +3 -0
  49. package/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +2 -9
  50. package/src/components/ui/money-input.test.tsx +64 -0
  51. package/src/components/ui/money-input.tsx +63 -0
  52. package/src/components/ui/optional-time-picker.tsx +95 -0
  53. package/src/components/ui/quick-command-center.test.tsx +90 -0
  54. package/src/components/ui/quick-command-center.tsx +190 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +126 -50
  56. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +23 -20
  58. package/src/components/ui/storefront/image-panel.tsx +6 -0
  59. package/src/components/ui/storefront/index.ts +11 -0
  60. package/src/components/ui/storefront/listing-card.tsx +84 -22
  61. package/src/components/ui/storefront/product-detail.tsx +289 -0
  62. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  63. package/src/components/ui/storefront/storefront-surface.test.tsx +132 -5
  64. package/src/components/ui/storefront/storefront-surface.tsx +371 -128
  65. package/src/components/ui/storefront/types.ts +25 -1
  66. package/src/components/ui/storefront/utils.ts +118 -13
  67. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  68. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  69. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  70. package/src/components/ui/text-editor/content-migration.ts +41 -18
  71. package/src/components/ui/text-editor/extensions.ts +1 -1
  72. package/src/components/ui/text-editor/image-extension.ts +40 -18
  73. package/src/components/ui/text-editor/video-extension.ts +11 -2
  74. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  75. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  76. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  77. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  78. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  79. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  80. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  81. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  82. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  83. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  84. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  85. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  86. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  87. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  88. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  89. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  90. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  91. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  92. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  93. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  94. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  95. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.test.ts +171 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +200 -36
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +21 -2
  102. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  103. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  104. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  105. package/src/hooks/useBoardRealtime.ts +6 -3
  106. package/src/hooks/useBoardRealtime.types.ts +11 -0
  107. package/src/hooks/useCursorTracking.ts +91 -27
  108. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -11,6 +11,7 @@ import { Separator } from '@tuturuuu/ui/separator';
11
11
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
12
12
  import { useTranslations } from 'next-intl';
13
13
  import { parseAsString, useQueryState } from 'nuqs';
14
+ import { useEffect } from 'react';
14
15
 
15
16
  interface Props {
16
17
  currency: string;
@@ -30,8 +31,22 @@ export default function CategoriesTagsTabs({
30
31
  shallow: true,
31
32
  })
32
33
  );
34
+ const [create, setCreate] = useQueryState(
35
+ 'create',
36
+ parseAsString.withDefault('').withOptions({
37
+ shallow: true,
38
+ })
39
+ );
33
40
 
34
41
  const activeTab = tab === 'tags' ? 'tags' : 'categories';
42
+ const categoryCreateOpen = create === 'category';
43
+ const tagCreateOpen = create === 'tag';
44
+
45
+ useEffect(() => {
46
+ if (tagCreateOpen && activeTab !== 'tags') {
47
+ setTab('tags');
48
+ }
49
+ }, [activeTab, setTab, tagCreateOpen]);
35
50
 
36
51
  return (
37
52
  <Tabs
@@ -58,6 +73,8 @@ export default function CategoriesTagsTabs({
58
73
  createTitle={t('ws-transaction-categories.create')}
59
74
  createDescription={t('ws-transaction-categories.create_description')}
60
75
  form={<TransactionCategoryForm wsId={wsId} />}
76
+ open={categoryCreateOpen}
77
+ setOpen={(open) => setCreate(open ? 'category' : null)}
61
78
  />
62
79
  <Separator className="my-4" />
63
80
  <CategoryBreakdownChart wsId={wsId} currency={currency} />
@@ -73,7 +90,12 @@ export default function CategoriesTagsTabs({
73
90
  </TabsContent>
74
91
 
75
92
  <TabsContent value="tags">
76
- <TagManager wsId={wsId} currency={currency} />
93
+ <TagManager
94
+ wsId={wsId}
95
+ currency={currency}
96
+ openCreateDialog={tagCreateOpen}
97
+ onOpenCreateDialogChange={(open) => setCreate(open ? 'tag' : null)}
98
+ />
77
99
  </TabsContent>
78
100
  </Tabs>
79
101
  );
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildFinanceCommandActionGroups } from './finance-command-actions';
3
+
4
+ const translate = (key: string) => key;
5
+
6
+ describe('buildFinanceCommandActionGroups', () => {
7
+ it('exposes create and utility commands when permissions allow them', () => {
8
+ const groups = buildFinanceCommandActionGroups({
9
+ permissions: {
10
+ canCreateDebts: true,
11
+ canCreateInvoices: true,
12
+ canCreateRecurringTransactions: true,
13
+ canCreateTransactions: true,
14
+ canCreateWallets: true,
15
+ canExportFinanceData: true,
16
+ canManageFinance: true,
17
+ canUpdateWallets: true,
18
+ },
19
+ tCommand: translate,
20
+ tFinance: translate,
21
+ });
22
+
23
+ const ids = groups.flatMap((group) => group.items.map((item) => item.id));
24
+
25
+ expect(ids).toContain('new-transaction');
26
+ expect(ids).toContain('new-transfer');
27
+ expect(ids).toContain('import-transactions');
28
+ expect(ids).toContain('export-transactions');
29
+ expect(ids).toContain('all-wallet-check');
30
+ });
31
+
32
+ it('hides transaction and wallet commands without permission', () => {
33
+ const groups = buildFinanceCommandActionGroups({
34
+ permissions: {
35
+ canCreateTransactions: false,
36
+ canCreateWallets: false,
37
+ },
38
+ tCommand: translate,
39
+ tFinance: translate,
40
+ });
41
+
42
+ const ids = groups.flatMap((group) => group.items.map((item) => item.id));
43
+
44
+ expect(ids).not.toContain('new-transaction');
45
+ expect(ids).not.toContain('new-transfer');
46
+ expect(ids).not.toContain('new-wallet');
47
+ });
48
+ });
@@ -0,0 +1,200 @@
1
+ import {
2
+ ArrowLeftRight,
3
+ Calculator,
4
+ CreditCard,
5
+ DollarSign,
6
+ Download,
7
+ FileText,
8
+ History,
9
+ Repeat,
10
+ Tag,
11
+ Target,
12
+ TrendingDown,
13
+ TrendingUp,
14
+ Upload,
15
+ Wallet,
16
+ } from '@tuturuuu/icons';
17
+ import type { ComponentType, ReactNode } from 'react';
18
+
19
+ type IconComponent = ComponentType<{ className?: string }>;
20
+ type Translation = (key: any) => string;
21
+
22
+ export interface FinanceCommandActionPermissions {
23
+ canCreateDebts?: boolean;
24
+ canCreateInvoices?: boolean;
25
+ canCreateRecurringTransactions?: boolean;
26
+ canCreateTransactions?: boolean;
27
+ canCreateWallets?: boolean;
28
+ canExportFinanceData?: boolean;
29
+ canManageFinance?: boolean;
30
+ canUpdateWallets?: boolean;
31
+ }
32
+
33
+ export interface FinanceCommandAction {
34
+ description?: string;
35
+ href: string;
36
+ icon: IconComponent;
37
+ id: string;
38
+ title: string;
39
+ }
40
+
41
+ export interface FinanceCommandActionGroup {
42
+ heading: string;
43
+ id: 'create' | 'tools';
44
+ items: FinanceCommandAction[];
45
+ }
46
+
47
+ function iconNode(Icon: IconComponent): ReactNode {
48
+ return <Icon className="h-4 w-4" />;
49
+ }
50
+
51
+ export function renderFinanceCommandActionIcon(action: FinanceCommandAction) {
52
+ return iconNode(action.icon);
53
+ }
54
+
55
+ export function buildFinanceCommandActionGroups({
56
+ permissions,
57
+ tCommand,
58
+ tFinance,
59
+ }: {
60
+ permissions: FinanceCommandActionPermissions;
61
+ tCommand: Translation;
62
+ tFinance: Translation;
63
+ }): FinanceCommandActionGroup[] {
64
+ const canCreateTransactions = permissions.canCreateTransactions ?? true;
65
+ const canCreateWallets = permissions.canCreateWallets ?? true;
66
+ const canManageFinance = permissions.canManageFinance ?? true;
67
+ const canCreateInvoices = permissions.canCreateInvoices ?? true;
68
+ const canCreateDebts = permissions.canCreateDebts ?? true;
69
+ const canCreateRecurringTransactions =
70
+ permissions.canCreateRecurringTransactions ?? true;
71
+
72
+ const groups: FinanceCommandActionGroup[] = [
73
+ {
74
+ heading: tCommand('create_group'),
75
+ id: 'create',
76
+ items: [
77
+ canCreateTransactions && {
78
+ description: tCommand('new_transaction_description'),
79
+ href: '/transactions?create=transaction',
80
+ icon: DollarSign,
81
+ id: 'new-transaction',
82
+ title: tFinance('new_transaction'),
83
+ },
84
+ canCreateTransactions && {
85
+ description: tCommand('new_transfer_description'),
86
+ href: '/transactions?create=transfer',
87
+ icon: ArrowLeftRight,
88
+ id: 'new-transfer',
89
+ title: tFinance('new_transfer'),
90
+ },
91
+ canCreateRecurringTransactions && {
92
+ description: tCommand('new_recurring_transaction_description'),
93
+ href: '/recurring?create=recurring',
94
+ icon: Repeat,
95
+ id: 'new-recurring-transaction',
96
+ title: tFinance('new_recurring_transaction'),
97
+ },
98
+ canCreateWallets && {
99
+ description: tCommand('new_wallet_description'),
100
+ href: '/wallets?create=wallet',
101
+ icon: Wallet,
102
+ id: 'new-wallet',
103
+ title: tFinance('new_wallet'),
104
+ },
105
+ canCreateWallets && {
106
+ description: tCommand('new_credit_card_description'),
107
+ href: '/wallets?create=credit-card',
108
+ icon: CreditCard,
109
+ id: 'new-credit-card',
110
+ title: tFinance('new_credit_card'),
111
+ },
112
+ canManageFinance && {
113
+ description: tCommand('new_budget_description'),
114
+ href: '/budgets?create=budget',
115
+ icon: Target,
116
+ id: 'new-budget',
117
+ title: tFinance('new_budget'),
118
+ },
119
+ canCreateInvoices && {
120
+ description: tCommand('new_invoice_description'),
121
+ href: '/invoices/new',
122
+ icon: FileText,
123
+ id: 'new-invoice',
124
+ title: tFinance('new_invoice'),
125
+ },
126
+ canCreateDebts && {
127
+ description: tCommand('new_debt_description'),
128
+ href: '/debts?create=debt',
129
+ icon: TrendingDown,
130
+ id: 'new-debt',
131
+ title: tFinance('new_debt'),
132
+ },
133
+ canCreateDebts && {
134
+ description: tCommand('new_loan_description'),
135
+ href: '/debts?create=loan',
136
+ icon: TrendingUp,
137
+ id: 'new-loan',
138
+ title: tFinance('new_loan'),
139
+ },
140
+ canManageFinance && {
141
+ description: tCommand('new_category_description'),
142
+ href: '/categories?create=category',
143
+ icon: CreditCard,
144
+ id: 'new-category',
145
+ title: tCommand('new_category'),
146
+ },
147
+ canManageFinance && {
148
+ description: tCommand('new_tag_description'),
149
+ href: '/categories?tab=tags&create=tag',
150
+ icon: Tag,
151
+ id: 'new-tag',
152
+ title: tCommand('new_tag'),
153
+ },
154
+ ].filter(Boolean) as FinanceCommandAction[],
155
+ },
156
+ {
157
+ heading: tCommand('tools_group'),
158
+ id: 'tools',
159
+ items: [
160
+ canCreateTransactions && {
161
+ description: tCommand('import_transactions_description'),
162
+ href: '/transactions?tool=import',
163
+ icon: Download,
164
+ id: 'import-transactions',
165
+ title: tCommand('import_transactions'),
166
+ },
167
+ permissions.canExportFinanceData && {
168
+ description: tCommand('export_transactions_description'),
169
+ href: '/transactions?tool=export',
170
+ icon: Upload,
171
+ id: 'export-transactions',
172
+ title: tCommand('export_transactions'),
173
+ },
174
+ permissions.canUpdateWallets && {
175
+ description: tCommand('all_wallet_check_description'),
176
+ href: '/wallets?tool=all-wallet-check',
177
+ icon: Calculator,
178
+ id: 'all-wallet-check',
179
+ title: tCommand('all_wallet_check'),
180
+ },
181
+ canCreateTransactions && {
182
+ description: tCommand('checkpoint_history_description'),
183
+ href: '/wallets?tool=checkpoint-history',
184
+ icon: History,
185
+ id: 'checkpoint-history',
186
+ title: tCommand('checkpoint_history'),
187
+ },
188
+ canManageFinance && {
189
+ description: tCommand('manage_categories_description'),
190
+ href: '/categories',
191
+ icon: CreditCard,
192
+ id: 'manage-categories',
193
+ title: tFinance('manage_categories'),
194
+ },
195
+ ].filter(Boolean) as FinanceCommandAction[],
196
+ },
197
+ ];
198
+
199
+ return groups.filter((group) => group.items.length > 0);
200
+ }
@@ -0,0 +1,151 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { FinanceRouteProvider } from '../finance-route-context';
5
+ import { FinanceCommandProvider } from './finance-command-provider';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ listBudgets: vi.fn(),
9
+ listDebtLoans: vi.fn(),
10
+ listFinanceInvoices: vi.fn(),
11
+ listInfiniteTransactionsWithInternalApi: vi.fn(),
12
+ listInfiniteWallets: vi.fn(),
13
+ listRecurringTransactions: vi.fn(),
14
+ listTransactionCategories: vi.fn(),
15
+ listTransactionTags: vi.fn(),
16
+ push: vi.fn(),
17
+ }));
18
+
19
+ vi.mock('next/navigation', () => ({
20
+ useRouter: () => ({
21
+ push: mocks.push,
22
+ }),
23
+ }));
24
+
25
+ vi.mock('next-intl', () => ({
26
+ useLocale: () => 'en',
27
+ useTranslations: () => (key: string) => {
28
+ const labels: Record<string, string> = {
29
+ create_group: 'Create',
30
+ description: 'Finance shortcuts',
31
+ empty: 'No commands',
32
+ finance: 'Finance',
33
+ new_transaction: 'New transaction',
34
+ new_transfer: 'New transfer',
35
+ quick_create_title: 'Quick create',
36
+ quick_placeholder: 'Create anything...',
37
+ recent_transactions: 'Recent transactions',
38
+ search_placeholder: 'Search finance...',
39
+ title: 'Finance command center',
40
+ };
41
+ return labels[key] ?? key;
42
+ },
43
+ }));
44
+
45
+ vi.mock('@tuturuuu/internal-api/finance', () => ({
46
+ listBudgets: (...args: Parameters<typeof mocks.listBudgets>) =>
47
+ mocks.listBudgets(...args),
48
+ listDebtLoans: (...args: Parameters<typeof mocks.listDebtLoans>) =>
49
+ mocks.listDebtLoans(...args),
50
+ listFinanceInvoices: (
51
+ ...args: Parameters<typeof mocks.listFinanceInvoices>
52
+ ) => mocks.listFinanceInvoices(...args),
53
+ listInfiniteWallets: (
54
+ ...args: Parameters<typeof mocks.listInfiniteWallets>
55
+ ) => mocks.listInfiniteWallets(...args),
56
+ listRecurringTransactions: (
57
+ ...args: Parameters<typeof mocks.listRecurringTransactions>
58
+ ) => mocks.listRecurringTransactions(...args),
59
+ listTransactionCategories: (
60
+ ...args: Parameters<typeof mocks.listTransactionCategories>
61
+ ) => mocks.listTransactionCategories(...args),
62
+ listTransactionTags: (
63
+ ...args: Parameters<typeof mocks.listTransactionTags>
64
+ ) => mocks.listTransactionTags(...args),
65
+ }));
66
+
67
+ vi.mock('../transactions/internal-api', () => ({
68
+ listInfiniteTransactionsWithInternalApi: (
69
+ ...args: Parameters<typeof mocks.listInfiniteTransactionsWithInternalApi>
70
+ ) => mocks.listInfiniteTransactionsWithInternalApi(...args),
71
+ }));
72
+
73
+ function renderProvider() {
74
+ const queryClient = new QueryClient({
75
+ defaultOptions: {
76
+ queries: {
77
+ retry: false,
78
+ },
79
+ },
80
+ });
81
+
82
+ return render(
83
+ <QueryClientProvider client={queryClient}>
84
+ <FinanceRouteProvider prefix="/finance">
85
+ <FinanceCommandProvider
86
+ wsId="ws-1"
87
+ workspaceSlug="ws-1"
88
+ canCreateTransactions
89
+ />
90
+ </FinanceRouteProvider>
91
+ </QueryClientProvider>
92
+ );
93
+ }
94
+
95
+ describe('FinanceCommandProvider', () => {
96
+ beforeEach(() => {
97
+ vi.clearAllMocks();
98
+ globalThis.ResizeObserver = class ResizeObserver {
99
+ disconnect() {}
100
+ observe() {}
101
+ unobserve() {}
102
+ };
103
+ Element.prototype.scrollIntoView = vi.fn();
104
+ mocks.listBudgets.mockResolvedValue([]);
105
+ mocks.listDebtLoans.mockResolvedValue([]);
106
+ mocks.listFinanceInvoices.mockResolvedValue({ data: [] });
107
+ mocks.listInfiniteTransactionsWithInternalApi.mockResolvedValue({
108
+ data: [],
109
+ });
110
+ mocks.listInfiniteWallets.mockResolvedValue({ data: [] });
111
+ mocks.listRecurringTransactions.mockResolvedValue([]);
112
+ mocks.listTransactionCategories.mockResolvedValue([]);
113
+ mocks.listTransactionTags.mockResolvedValue([]);
114
+ });
115
+
116
+ it('opens quick create with C and routes digit shortcuts', () => {
117
+ renderProvider();
118
+
119
+ fireEvent.keyDown(window, { key: 'c' });
120
+
121
+ expect(screen.getByText('New transaction')).toBeVisible();
122
+
123
+ fireEvent.keyDown(window, { key: '1' });
124
+
125
+ expect(mocks.push).toHaveBeenCalledWith(
126
+ '/ws-1/finance/transactions?create=transaction'
127
+ );
128
+ });
129
+
130
+ it('opens finance search with command-k and loads recent transactions', async () => {
131
+ mocks.listInfiniteTransactionsWithInternalApi.mockResolvedValue({
132
+ data: [
133
+ {
134
+ amount: -12,
135
+ description: 'Lunch',
136
+ id: 'tx-1',
137
+ taken_at: '2026-06-13T12:00:00.000Z',
138
+ wallet_currency: 'USD',
139
+ wallet_name: 'Cash',
140
+ },
141
+ ],
142
+ });
143
+
144
+ renderProvider();
145
+
146
+ fireEvent.keyDown(window, { key: 'k', metaKey: true });
147
+
148
+ expect(screen.getByPlaceholderText('Search finance...')).toBeVisible();
149
+ expect(await screen.findByText('Lunch')).toBeVisible();
150
+ });
151
+ });
@@ -0,0 +1,250 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import {
5
+ listBudgets,
6
+ listDebtLoans,
7
+ listFinanceInvoices,
8
+ listInfiniteWallets,
9
+ listRecurringTransactions,
10
+ listTransactionCategories,
11
+ listTransactionTags,
12
+ } from '@tuturuuu/internal-api/finance';
13
+ import type { QuickCommandCenterGroup } from '@tuturuuu/ui/quick-command-center';
14
+ import { QuickCommandCenter } from '@tuturuuu/ui/quick-command-center';
15
+ import { useRouter } from 'next/navigation';
16
+ import { useLocale, useTranslations } from 'next-intl';
17
+ import { useCallback, useEffect, useMemo, useState } from 'react';
18
+ import { useFinanceHref } from '../finance-route-context';
19
+ import { listInfiniteTransactionsWithInternalApi } from '../transactions/internal-api';
20
+ import {
21
+ buildFinanceCommandActionGroups,
22
+ type FinanceCommandActionPermissions,
23
+ renderFinanceCommandActionIcon,
24
+ } from './finance-command-actions';
25
+ import { buildFinanceRecentCommandGroups } from './finance-command-results';
26
+
27
+ interface FinanceCommandProviderProps extends FinanceCommandActionPermissions {
28
+ currency?: string;
29
+ workspaceSlug: string;
30
+ wsId: string;
31
+ }
32
+
33
+ type CommandMode = 'quick-create' | 'search';
34
+
35
+ function isEditableTarget(target: EventTarget | null) {
36
+ return (
37
+ target instanceof HTMLElement &&
38
+ !!target.closest(
39
+ 'input, textarea, select, [contenteditable="true"], [role="textbox"]'
40
+ )
41
+ );
42
+ }
43
+
44
+ export function FinanceCommandProvider({
45
+ currency = 'USD',
46
+ workspaceSlug,
47
+ wsId,
48
+ ...permissions
49
+ }: FinanceCommandProviderProps) {
50
+ const router = useRouter();
51
+ const locale = useLocale();
52
+ const tCommand = useTranslations('finance-command-center');
53
+ const tFinance = useTranslations('finance');
54
+ const financeHref = useFinanceHref();
55
+ const [open, setOpen] = useState(false);
56
+ const [mode, setMode] = useState<CommandMode>('quick-create');
57
+ const [search, setSearch] = useState('');
58
+ const trimmedSearch = search.trim();
59
+ const searchEnabled = open && mode === 'search';
60
+
61
+ const pushFinanceHref = useCallback(
62
+ (path: string) => {
63
+ setOpen(false);
64
+ setSearch('');
65
+ router.push(`/${workspaceSlug}${financeHref(path)}`);
66
+ },
67
+ [financeHref, router, workspaceSlug]
68
+ );
69
+
70
+ useEffect(() => {
71
+ const handleFinanceShortcut = (event: KeyboardEvent) => {
72
+ if (event.isComposing || isEditableTarget(event.target)) return;
73
+
74
+ if (
75
+ event.key.toLowerCase() === 'k' &&
76
+ (event.metaKey || event.ctrlKey) &&
77
+ !event.altKey
78
+ ) {
79
+ event.preventDefault();
80
+ event.stopPropagation();
81
+ event.stopImmediatePropagation();
82
+ setMode('search');
83
+ setOpen(true);
84
+ return;
85
+ }
86
+
87
+ if (
88
+ event.key.toLowerCase() === 'c' &&
89
+ !(event.metaKey || event.ctrlKey || event.altKey)
90
+ ) {
91
+ event.preventDefault();
92
+ event.stopPropagation();
93
+ event.stopImmediatePropagation();
94
+ setMode('quick-create');
95
+ setOpen(true);
96
+ }
97
+ };
98
+
99
+ window.addEventListener('keydown', handleFinanceShortcut, {
100
+ capture: true,
101
+ });
102
+
103
+ return () =>
104
+ window.removeEventListener('keydown', handleFinanceShortcut, {
105
+ capture: true,
106
+ });
107
+ }, []);
108
+
109
+ const transactionsQuery = useQuery({
110
+ enabled: searchEnabled,
111
+ queryFn: () =>
112
+ listInfiniteTransactionsWithInternalApi(wsId, {
113
+ limit: 6,
114
+ q: trimmedSearch || undefined,
115
+ }),
116
+ queryKey: ['finance-command-center', wsId, 'transactions', trimmedSearch],
117
+ });
118
+ const walletsQuery = useQuery({
119
+ enabled: searchEnabled,
120
+ queryFn: () =>
121
+ listInfiniteWallets(wsId, {
122
+ limit: 6,
123
+ q: trimmedSearch || undefined,
124
+ }),
125
+ queryKey: ['finance-command-center', wsId, 'wallets', trimmedSearch],
126
+ });
127
+ const invoicesQuery = useQuery({
128
+ enabled: searchEnabled,
129
+ queryFn: () =>
130
+ listFinanceInvoices(wsId, {
131
+ page: 1,
132
+ pageSize: 6,
133
+ q: trimmedSearch || undefined,
134
+ }),
135
+ queryKey: ['finance-command-center', wsId, 'invoices', trimmedSearch],
136
+ });
137
+ const budgetsQuery = useQuery({
138
+ enabled: searchEnabled,
139
+ queryFn: () => listBudgets(wsId),
140
+ queryKey: ['finance-command-center', wsId, 'budgets'],
141
+ });
142
+ const debtsQuery = useQuery({
143
+ enabled: searchEnabled,
144
+ queryFn: () => listDebtLoans(wsId),
145
+ queryKey: ['finance-command-center', wsId, 'debts'],
146
+ });
147
+ const recurringQuery = useQuery({
148
+ enabled: searchEnabled,
149
+ queryFn: () => listRecurringTransactions(wsId),
150
+ queryKey: ['finance-command-center', wsId, 'recurring'],
151
+ });
152
+ const categoriesQuery = useQuery({
153
+ enabled: searchEnabled,
154
+ queryFn: () => listTransactionCategories(wsId),
155
+ queryKey: ['finance-command-center', wsId, 'categories'],
156
+ });
157
+ const tagsQuery = useQuery({
158
+ enabled: searchEnabled,
159
+ queryFn: () => listTransactionTags(wsId),
160
+ queryKey: ['finance-command-center', wsId, 'tags'],
161
+ });
162
+
163
+ const actionGroups = useMemo<QuickCommandCenterGroup[]>(
164
+ () =>
165
+ buildFinanceCommandActionGroups({
166
+ permissions,
167
+ tCommand,
168
+ tFinance,
169
+ }).map((group) => ({
170
+ heading: group.heading,
171
+ id: group.id,
172
+ items: group.items.map((action) => ({
173
+ description: action.description,
174
+ icon: renderFinanceCommandActionIcon(action),
175
+ id: action.id,
176
+ keywords: [action.href],
177
+ onSelect: () => pushFinanceHref(action.href),
178
+ title: action.title,
179
+ })),
180
+ })),
181
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `permissions` is the rest-spread of stable boolean props; its identity churns each render but the memo body is a cheap synchronous mapping.
182
+ [permissions, pushFinanceHref, tCommand, tFinance]
183
+ );
184
+
185
+ const recentGroups = useMemo<QuickCommandCenterGroup[]>(() => {
186
+ if (mode !== 'search') return [];
187
+
188
+ return buildFinanceRecentCommandGroups({
189
+ budgets: budgetsQuery.data,
190
+ categories: categoriesQuery.data,
191
+ currency,
192
+ debts: debtsQuery.data,
193
+ invoices: invoicesQuery.data?.data,
194
+ locale,
195
+ pushFinanceHref,
196
+ recurring: recurringQuery.data,
197
+ search: trimmedSearch,
198
+ tags: tagsQuery.data,
199
+ tCommand,
200
+ tFinance,
201
+ transactions: transactionsQuery.data?.data,
202
+ wallets: walletsQuery.data?.data,
203
+ });
204
+ }, [
205
+ budgetsQuery.data,
206
+ categoriesQuery.data,
207
+ currency,
208
+ debtsQuery.data,
209
+ invoicesQuery.data,
210
+ locale,
211
+ mode,
212
+ pushFinanceHref,
213
+ recurringQuery.data,
214
+ tagsQuery.data,
215
+ tCommand,
216
+ tFinance,
217
+ transactionsQuery.data,
218
+ trimmedSearch,
219
+ walletsQuery.data,
220
+ ]);
221
+
222
+ const groups =
223
+ mode === 'quick-create' ? actionGroups : [...actionGroups, ...recentGroups];
224
+
225
+ return (
226
+ <QuickCommandCenter
227
+ digitShortcuts={mode === 'quick-create'}
228
+ emptyLabel={tCommand('empty')}
229
+ groups={groups}
230
+ onOpenChange={(nextOpen) => {
231
+ setOpen(nextOpen);
232
+ if (!nextOpen) setSearch('');
233
+ }}
234
+ onSearchValueChange={setSearch}
235
+ open={open}
236
+ placeholder={
237
+ mode === 'quick-create'
238
+ ? tCommand('quick_placeholder')
239
+ : tCommand('search_placeholder')
240
+ }
241
+ searchValue={search}
242
+ title={
243
+ mode === 'quick-create'
244
+ ? tCommand('quick_create_title')
245
+ : tCommand('title')
246
+ }
247
+ description={tCommand('description')}
248
+ />
249
+ );
250
+ }