@tuturuuu/ui 0.13.0 → 0.16.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 (96) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings/task-settings.tsx +55 -0
  12. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  13. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  14. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  15. package/src/components/ui/custom/statistic-card.tsx +104 -0
  16. package/src/components/ui/custom/structure.test.tsx +29 -0
  17. package/src/components/ui/custom/structure.tsx +103 -88
  18. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  20. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  23. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  24. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  25. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  26. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  27. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  28. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  29. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  30. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  31. package/src/components/ui/finance/invoices/utils.ts +56 -134
  32. package/src/components/ui/resizable.test.ts +15 -0
  33. package/src/components/ui/resizable.tsx +28 -6
  34. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  36. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  37. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  38. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
  40. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  42. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  43. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  44. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  45. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  46. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  47. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  48. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  49. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  50. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  51. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  52. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  53. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  54. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  55. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  56. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  57. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  58. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  59. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  60. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  61. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  62. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  63. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  64. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  65. package/src/components/ui/tu-do/projects/types.ts +4 -1
  66. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  67. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  68. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  69. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  70. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  71. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  72. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  73. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  74. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  75. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  76. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  83. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  84. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  85. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  86. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  87. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  88. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  89. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  90. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  91. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  92. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  93. package/src/hooks/task-actions-personal-external.ts +12 -6
  94. package/src/hooks/use-notifications.ts +14 -2
  95. package/src/hooks/use-user-config.ts +10 -2
  96. package/vitest.config.ts +4 -0
@@ -11,6 +11,7 @@ const invoiceMocks = vi.hoisted(() => ({
11
11
 
12
12
  const nuqsState = vi.hoisted(() => ({
13
13
  invoiceType: 'standard' as 'standard' | 'subscription',
14
+ values: new Map<string, number | null>(),
14
15
  }));
15
16
 
16
17
  vi.mock('next-intl', () => ({
@@ -23,7 +24,11 @@ vi.mock('nuqs', () => ({
23
24
  return [nuqsState.invoiceType, vi.fn()];
24
25
  }
25
26
 
26
- if (key === 'amount') {
27
+ if (nuqsState.values.has(key)) {
28
+ return [nuqsState.values.get(key), vi.fn()];
29
+ }
30
+
31
+ if (['amount', 'billable_quantity', 'suggested_total'].includes(key)) {
27
32
  return [null, vi.fn()];
28
33
  }
29
34
 
@@ -80,11 +85,13 @@ describe('NewInvoicePage', () => {
80
85
  invoiceMocks.StandardInvoice.mockClear();
81
86
  invoiceMocks.SubscriptionInvoice.mockClear();
82
87
  nuqsState.invoiceType = 'standard';
88
+ nuqsState.values.clear();
83
89
  fetchMock = vi.fn().mockResolvedValue({
84
90
  ok: true,
85
91
  json: async () => ({
86
92
  DEFAULT_CURRENCY: 'SGD',
87
93
  DEFAULT_SUBSCRIPTION_CATEGORY_ID: 'category-1',
94
+ default_category_id: 'category-general',
88
95
  default_wallet_id: 'wallet-1',
89
96
  }),
90
97
  });
@@ -101,7 +108,7 @@ describe('NewInvoicePage', () => {
101
108
  await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
102
109
 
103
110
  expect(fetchMock).toHaveBeenCalledWith(
104
- '/api/v1/workspaces/ws-1/settings/configs?ids=default_wallet_id,DEFAULT_SUBSCRIPTION_CATEGORY_ID,DEFAULT_CURRENCY',
111
+ '/api/v1/workspaces/ws-1/settings/configs?ids=default_wallet_id,default_category_id,DEFAULT_SUBSCRIPTION_CATEGORY_ID,DEFAULT_CURRENCY',
105
112
  { cache: 'no-store' }
106
113
  );
107
114
  expect(invoiceMocks.StandardInvoice).toHaveBeenCalled();
@@ -110,6 +117,7 @@ describe('NewInvoicePage', () => {
110
117
  await waitFor(() =>
111
118
  expect(invoiceMocks.StandardInvoice).toHaveBeenLastCalledWith(
112
119
  expect.objectContaining({
120
+ defaultCategoryId: 'category-general',
113
121
  defaultCurrency: 'SGD',
114
122
  defaultWalletId: 'wallet-1',
115
123
  })
@@ -133,6 +141,86 @@ describe('NewInvoicePage', () => {
133
141
  defaultCategoryId: 'category-1',
134
142
  defaultCurrency: 'SGD',
135
143
  defaultWalletId: 'wallet-1',
144
+ workspaceTimezone: undefined,
145
+ })
146
+ )
147
+ );
148
+ });
149
+
150
+ it('forwards a pending invoice quantity and suggested monetary total', async () => {
151
+ nuqsState.invoiceType = 'subscription';
152
+ nuqsState.values.set('billable_quantity', 3);
153
+ nuqsState.values.set('suggested_total', 1_500_000);
154
+
155
+ renderPage();
156
+
157
+ await waitFor(() =>
158
+ expect(invoiceMocks.SubscriptionInvoice).toHaveBeenLastCalledWith(
159
+ expect.objectContaining({
160
+ prefillQuantity: 3,
161
+ suggestedTotal: 1_500_000,
162
+ })
163
+ )
164
+ );
165
+ });
166
+
167
+ it('keeps legacy amount links working as a quantity fallback', async () => {
168
+ nuqsState.invoiceType = 'subscription';
169
+ nuqsState.values.set('amount', 4);
170
+
171
+ renderPage();
172
+
173
+ await waitFor(() =>
174
+ expect(invoiceMocks.SubscriptionInvoice).toHaveBeenLastCalledWith(
175
+ expect.objectContaining({ prefillQuantity: 4 })
176
+ )
177
+ );
178
+ });
179
+
180
+ it('forwards server-seeded defaults and timezone before config refresh completes', async () => {
181
+ nuqsState.invoiceType = 'subscription';
182
+ fetchMock.mockReturnValueOnce(new Promise(() => {}));
183
+
184
+ renderPage({
185
+ defaultCurrency: 'VND',
186
+ initialDefaultCategoryId: 'category-general-server',
187
+ initialDefaultSubscriptionCategoryId: 'category-subscription-server',
188
+ initialDefaultWalletId: 'wallet-server',
189
+ workspaceTimezone: 'Asia/Ho_Chi_Minh',
190
+ });
191
+
192
+ await waitFor(() =>
193
+ expect(invoiceMocks.SubscriptionInvoice).toHaveBeenLastCalledWith(
194
+ expect.objectContaining({
195
+ defaultCategoryId: 'category-subscription-server',
196
+ defaultCurrency: 'VND',
197
+ defaultWalletId: 'wallet-server',
198
+ workspaceTimezone: 'Asia/Ho_Chi_Minh',
199
+ })
200
+ )
201
+ );
202
+ });
203
+
204
+ it('falls subscription invoice category back to the transaction default', async () => {
205
+ nuqsState.invoiceType = 'subscription';
206
+ fetchMock.mockResolvedValueOnce({
207
+ ok: true,
208
+ json: async () => ({
209
+ DEFAULT_CURRENCY: 'SGD',
210
+ DEFAULT_SUBSCRIPTION_CATEGORY_ID: null,
211
+ default_category_id: 'category-general',
212
+ default_wallet_id: 'wallet-1',
213
+ }),
214
+ });
215
+
216
+ renderPage();
217
+
218
+ await waitFor(() =>
219
+ expect(invoiceMocks.SubscriptionInvoice).toHaveBeenLastCalledWith(
220
+ expect.objectContaining({
221
+ defaultCategoryId: 'category-general',
222
+ defaultCurrency: 'SGD',
223
+ defaultWalletId: 'wallet-1',
136
224
  })
137
225
  )
138
226
  );
@@ -27,6 +27,7 @@ import { SubscriptionInvoice } from './subscription-invoice';
27
27
 
28
28
  const INVOICE_DEFAULT_CONFIG_IDS = [
29
29
  'default_wallet_id',
30
+ 'default_category_id',
30
31
  'DEFAULT_SUBSCRIPTION_CATEGORY_ID',
31
32
  'DEFAULT_CURRENCY',
32
33
  ] as const;
@@ -40,7 +41,11 @@ interface Props {
40
41
  canReadInvoiceProductStock?: boolean;
41
42
  canReadGroupLinkedProducts?: boolean;
42
43
  defaultCurrency?: string;
44
+ initialDefaultCategoryId?: string | null;
45
+ initialDefaultSubscriptionCategoryId?: string | null;
46
+ initialDefaultWalletId?: string | null;
43
47
  permissionRequestUser?: FinancePermissionRequestUser | null;
48
+ workspaceTimezone?: string | null;
44
49
  }
45
50
 
46
51
  export default function NewInvoicePage({
@@ -52,7 +57,11 @@ export default function NewInvoicePage({
52
57
  canReadInvoiceProductStock = true,
53
58
  canReadGroupLinkedProducts = true,
54
59
  defaultCurrency: workspaceDefaultCurrency,
60
+ initialDefaultCategoryId,
61
+ initialDefaultSubscriptionCategoryId,
62
+ initialDefaultWalletId,
55
63
  permissionRequestUser,
64
+ workspaceTimezone,
56
65
  }: Props) {
57
66
  const t = useTranslations();
58
67
  const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -63,20 +72,44 @@ export default function NewInvoicePage({
63
72
  serialize: (value) => value,
64
73
  });
65
74
 
66
- const [prefillAmount] = useQueryState('amount', {
75
+ const [billableQuantity] = useQueryState('billable_quantity', {
67
76
  parse: (value) => {
68
77
  const parsed = Number(value);
69
78
  return Number.isFinite(parsed) ? parsed : null;
70
79
  },
71
80
  });
81
+ const [legacyPrefillAmount] = useQueryState('amount', {
82
+ parse: (value) => {
83
+ const parsed = Number(value);
84
+ return Number.isFinite(parsed) ? parsed : null;
85
+ },
86
+ });
87
+ const [suggestedTotal] = useQueryState('suggested_total', {
88
+ parse: (value) => {
89
+ const parsed = Number(value);
90
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
91
+ },
92
+ });
72
93
 
73
- const { data: defaultConfigs = {} } = useWorkspaceConfigs(
94
+ const { data: refreshedDefaultConfigs = {} } = useWorkspaceConfigs(
74
95
  wsId,
75
96
  INVOICE_DEFAULT_CONFIG_IDS
76
97
  );
98
+ const defaultConfigs = {
99
+ DEFAULT_CURRENCY: workspaceDefaultCurrency ?? null,
100
+ DEFAULT_SUBSCRIPTION_CATEGORY_ID:
101
+ initialDefaultSubscriptionCategoryId ?? null,
102
+ default_category_id: initialDefaultCategoryId ?? null,
103
+ default_wallet_id: initialDefaultWalletId ?? null,
104
+ ...refreshedDefaultConfigs,
105
+ };
77
106
  const defaultWalletId = defaultConfigs.default_wallet_id ?? undefined;
78
- const defaultCategoryId =
107
+ const defaultTransactionCategoryId =
108
+ defaultConfigs.default_category_id ?? undefined;
109
+ const defaultSubscriptionCategoryId =
79
110
  defaultConfigs.DEFAULT_SUBSCRIPTION_CATEGORY_ID ?? undefined;
111
+ const defaultCategoryId =
112
+ defaultSubscriptionCategoryId ?? defaultTransactionCategoryId;
80
113
  const defaultCurrency = resolveSupportedCurrency(
81
114
  defaultConfigs.DEFAULT_CURRENCY,
82
115
  resolveSupportedCurrency(workspaceDefaultCurrency)
@@ -231,6 +264,7 @@ export default function NewInvoicePage({
231
264
  <StandardInvoice
232
265
  wsId={wsId}
233
266
  defaultWalletId={defaultWalletId}
267
+ defaultCategoryId={defaultTransactionCategoryId}
234
268
  defaultCurrency={defaultCurrency}
235
269
  canChangeFinanceWallets={canChangeFinanceWallets}
236
270
  canSetFinanceWalletsOnCreate={canSetFinanceWalletsOnCreate}
@@ -247,10 +281,12 @@ export default function NewInvoicePage({
247
281
  {invoiceType === 'subscription' ? (
248
282
  <SubscriptionInvoice
249
283
  wsId={wsId}
250
- prefillAmount={prefillAmount}
284
+ prefillQuantity={billableQuantity ?? legacyPrefillAmount}
285
+ suggestedTotal={suggestedTotal}
251
286
  defaultWalletId={defaultWalletId}
252
287
  defaultCategoryId={defaultCategoryId}
253
288
  defaultCurrency={defaultCurrency}
289
+ workspaceTimezone={workspaceTimezone}
254
290
  canChangeFinanceWallets={canChangeFinanceWallets}
255
291
  canSetFinanceWalletsOnCreate={canSetFinanceWalletsOnCreate}
256
292
  canReadInvoiceProducts={canReadInvoiceProducts}
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildPendingInvoiceCreateUrl } from './pending-columns';
3
+
4
+ describe('buildPendingInvoiceCreateUrl', () => {
5
+ it('carries both the billable quantity and suggested monetary total', () => {
6
+ const url = new URL(
7
+ buildPendingInvoiceCreateUrl({
8
+ attendanceDays: 3,
9
+ financePrefix: '',
10
+ groupIds: ['group-1', 'group-2'],
11
+ month: '2026-07',
12
+ potentialTotal: 1_500_000,
13
+ useAttendanceBased: true,
14
+ userId: 'user-1',
15
+ wsId: 'ws-1',
16
+ }),
17
+ 'https://finance.test'
18
+ );
19
+
20
+ expect(url.pathname).toBe('/ws-1/invoices/new');
21
+ expect(url.searchParams.get('billable_quantity')).toBe('3');
22
+ expect(url.searchParams.get('suggested_total')).toBe('1500000');
23
+ expect(url.searchParams.get('amount')).toBeNull();
24
+ expect(url.searchParams.get('group_ids')).toBe('group-1,group-2');
25
+ });
26
+ });
@@ -21,6 +21,40 @@ type PendingInvoiceRow = PendingInvoice & {
21
21
  group_names?: string[];
22
22
  };
23
23
 
24
+ export function buildPendingInvoiceCreateUrl({
25
+ attendanceDays,
26
+ financePrefix,
27
+ groupIds,
28
+ month,
29
+ potentialTotal,
30
+ useAttendanceBased,
31
+ userId,
32
+ wsId,
33
+ }: {
34
+ attendanceDays: number;
35
+ financePrefix: string;
36
+ groupIds: string[];
37
+ month: string;
38
+ potentialTotal: number;
39
+ useAttendanceBased: boolean;
40
+ userId: string;
41
+ wsId: string;
42
+ }) {
43
+ const searchParams = new URLSearchParams({
44
+ type: 'subscription',
45
+ user_id: userId,
46
+ });
47
+
48
+ if (month) searchParams.set('month', month);
49
+ if (useAttendanceBased) {
50
+ searchParams.set('billable_quantity', String(attendanceDays));
51
+ }
52
+ searchParams.set('suggested_total', String(potentialTotal));
53
+ if (groupIds.length > 0) searchParams.set('group_ids', groupIds.join(','));
54
+
55
+ return `/${wsId}${financePrefix}/invoices/new?${searchParams.toString()}`;
56
+ }
57
+
24
58
  function PendingInvoicePotentialTotalCell({
25
59
  amount,
26
60
  currency,
@@ -268,6 +302,7 @@ export const pendingInvoiceColumns = (
268
302
  ).filter((id: string) => Boolean(id));
269
303
  const monthsOwed = row.getValue<string[]>('months_owed');
270
304
  const attendanceDays = row.getValue<number>('attendance_days');
305
+ const potentialTotal = row.getValue<number>('potential_total') || 0;
271
306
 
272
307
  // Get the LAST (most recent) unpaid month from the array
273
308
  const lastUnpaidMonth =
@@ -277,20 +312,16 @@ export const pendingInvoiceColumns = (
277
312
 
278
313
  if (!wsId) return null;
279
314
 
280
- const searchParams = new URLSearchParams();
281
- searchParams.set('type', 'subscription');
282
- searchParams.set('user_id', userId);
283
- if (lastUnpaidMonth) {
284
- searchParams.set('month', lastUnpaidMonth);
285
- }
286
- if (useAttendanceBased) {
287
- searchParams.set('amount', String(attendanceDays));
288
- }
289
- if (groupIds.length > 0) {
290
- searchParams.set('group_ids', groupIds.join(','));
291
- }
292
-
293
- const createInvoiceUrl = `/${wsId}${financePrefix}/invoices/new?${searchParams.toString()}`;
315
+ const createInvoiceUrl = buildPendingInvoiceCreateUrl({
316
+ attendanceDays,
317
+ financePrefix,
318
+ groupIds,
319
+ month: lastUnpaidMonth ?? '',
320
+ potentialTotal,
321
+ useAttendanceBased,
322
+ userId,
323
+ wsId,
324
+ });
294
325
 
295
326
  return (
296
327
  <div className="flex items-center gap-2">
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveStandardInvoiceCategoryId } from './standard-invoice';
3
+
4
+ describe('resolveStandardInvoiceCategoryId', () => {
5
+ it('prefers a single linked product finance category', () => {
6
+ expect(
7
+ resolveStandardInvoiceCategoryId({
8
+ defaultCategoryId: 'category-default',
9
+ linkedFinanceCategoryIds: ['category-linked'],
10
+ })
11
+ ).toBe('category-linked');
12
+ });
13
+
14
+ it('requires explicit selection when linked product finance categories are mixed', () => {
15
+ expect(
16
+ resolveStandardInvoiceCategoryId({
17
+ defaultCategoryId: 'category-default',
18
+ linkedFinanceCategoryIds: ['category-a', 'category-b'],
19
+ })
20
+ ).toBe('');
21
+ });
22
+
23
+ it('falls back to the transaction default when products do not provide a category', () => {
24
+ expect(
25
+ resolveStandardInvoiceCategoryId({
26
+ defaultCategoryId: 'category-default',
27
+ linkedFinanceCategoryIds: [],
28
+ })
29
+ ).toBe('category-default');
30
+ });
31
+ });
@@ -57,6 +57,7 @@ interface Props {
57
57
  printAfterCreate?: boolean;
58
58
  downloadImageAfterCreate?: boolean;
59
59
  defaultWalletId?: string;
60
+ defaultCategoryId?: string;
60
61
  defaultCurrency?: string;
61
62
  canChangeFinanceWallets?: boolean;
62
63
  canSetFinanceWalletsOnCreate?: boolean;
@@ -71,6 +72,7 @@ export function StandardInvoice({
71
72
  printAfterCreate = false,
72
73
  downloadImageAfterCreate = false,
73
74
  defaultWalletId,
75
+ defaultCategoryId,
74
76
  defaultCurrency: rawDefaultCurrency = 'USD',
75
77
  canChangeFinanceWallets = true,
76
78
  canSetFinanceWalletsOnCreate = true,
@@ -194,7 +196,9 @@ export function StandardInvoice({
194
196
  }, [defaultWalletId]);
195
197
  const [selectedPromotionId, setSelectedPromotionId] =
196
198
  useState<string>('none');
197
- const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
199
+ const [selectedCategoryId, setSelectedCategoryId] = useState<string>(
200
+ defaultCategoryId || ''
201
+ );
198
202
  const [invoiceContent, setInvoiceContent] = useState<string>('');
199
203
  const [invoiceNotes, setInvoiceNotes] = useState<string>('');
200
204
  const [isCreating, setIsCreating] = useState(false);
@@ -280,7 +284,6 @@ export function StandardInvoice({
280
284
  [selectedProducts]
281
285
  );
282
286
  const hasMixedLinkedFinanceCategories = linkedFinanceCategoryIds.length > 1;
283
- const hasSingleLinkedFinanceCategory = linkedFinanceCategoryIds.length === 1;
284
287
 
285
288
  // Auto-generate invoice content based on selected products
286
289
  useEffect(() => {
@@ -318,19 +321,13 @@ export function StandardInvoice({
318
321
  }, [selectedUserId]);
319
322
 
320
323
  useEffect(() => {
321
- if (hasSingleLinkedFinanceCategory) {
322
- setSelectedCategoryId(linkedFinanceCategoryIds[0] ?? '');
323
- return;
324
- }
325
-
326
- if (hasMixedLinkedFinanceCategories) {
327
- setSelectedCategoryId('');
328
- }
329
- }, [
330
- hasMixedLinkedFinanceCategories,
331
- hasSingleLinkedFinanceCategory,
332
- linkedFinanceCategoryIds,
333
- ]);
324
+ setSelectedCategoryId(
325
+ resolveStandardInvoiceCategoryId({
326
+ defaultCategoryId,
327
+ linkedFinanceCategoryIds,
328
+ })
329
+ );
330
+ }, [defaultCategoryId, linkedFinanceCategoryIds]);
334
331
 
335
332
  useBestPromotionSelection({
336
333
  enabled: promotionsAllowed,
@@ -422,7 +419,7 @@ export function StandardInvoice({
422
419
  resetRounding();
423
420
  setSelectedUserId(null);
424
421
  setSelectedWalletId(defaultWalletId || '');
425
- setSelectedCategoryId('');
422
+ setSelectedCategoryId(defaultCategoryId || '');
426
423
  setCustomerSearch('');
427
424
  }
428
425
  } catch (error) {
@@ -657,3 +654,23 @@ export function StandardInvoice({
657
654
  </div>
658
655
  );
659
656
  }
657
+
658
+ export function resolveStandardInvoiceCategoryId({
659
+ defaultCategoryId,
660
+ linkedFinanceCategoryIds,
661
+ }: {
662
+ defaultCategoryId?: string;
663
+ linkedFinanceCategoryIds: string[];
664
+ }) {
665
+ const uniqueLinkedCategoryIds = [...new Set(linkedFinanceCategoryIds)];
666
+
667
+ if (uniqueLinkedCategoryIds.length === 1) {
668
+ return uniqueLinkedCategoryIds[0] ?? '';
669
+ }
670
+
671
+ if (uniqueLinkedCategoryIds.length > 1) {
672
+ return '';
673
+ }
674
+
675
+ return defaultCategoryId || '';
676
+ }