@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
@@ -0,0 +1,331 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { render, waitFor } from '@testing-library/react';
3
+ import type { ComponentProps, PropsWithChildren } from 'react';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { SubscriptionInvoice } from './subscription-invoice';
6
+
7
+ const testState = vi.hoisted(() => {
8
+ const inventory = {
9
+ amount: 5,
10
+ min_amount: 1,
11
+ price: 100,
12
+ unit_id: 'unit-1',
13
+ unit_name: 'Seat',
14
+ warehouse_id: 'warehouse-1',
15
+ warehouse_name: 'Main',
16
+ };
17
+ const product = {
18
+ category: null,
19
+ category_id: 'inventory-category',
20
+ created_at: null,
21
+ description: null,
22
+ finance_category_id: null as string | null,
23
+ id: 'product-1',
24
+ inventory: [inventory],
25
+ manufacturer: null,
26
+ name: 'Subscription seat',
27
+ usage: null,
28
+ ws_id: 'ws-1',
29
+ };
30
+
31
+ return {
32
+ InvoicePaymentSettings: vi.fn(),
33
+ categories: [] as Array<{ id: string; name: string }>,
34
+ groupIds: ['group-1'],
35
+ month: '2026-07',
36
+ productSelectionInjected: false,
37
+ products: [product],
38
+ selectedProducts: [{ inventory, product, quantity: 1 }],
39
+ useCategories: vi.fn(),
40
+ useWallets: vi.fn(),
41
+ userId: 'user-1',
42
+ wallets: [] as Array<{
43
+ currency: string;
44
+ id: string;
45
+ name: string;
46
+ type: string;
47
+ }>,
48
+ };
49
+ });
50
+
51
+ vi.mock('next-intl', () => ({
52
+ useLocale: () => 'en-US',
53
+ useTranslations: () => (key: string) => key,
54
+ }));
55
+
56
+ vi.mock('next/navigation', () => ({
57
+ useRouter: () => ({ push: vi.fn() }),
58
+ }));
59
+
60
+ vi.mock('nuqs', () => ({
61
+ parseAsArrayOf: () => ({
62
+ withDefault: () => ({
63
+ withOptions: () => ({}),
64
+ }),
65
+ }),
66
+ parseAsString: {},
67
+ useQueryState: (key: string, options?: { defaultValue?: unknown }) => {
68
+ if (key === 'user_id') return [testState.userId, vi.fn()];
69
+ if (key === 'group_ids') return [testState.groupIds, vi.fn()];
70
+ if (key === 'month') return [testState.month, vi.fn()];
71
+ return [options?.defaultValue ?? '', vi.fn()];
72
+ },
73
+ }));
74
+
75
+ vi.mock('../../../../hooks/use-debounce', () => ({
76
+ useDebounce: (value: string) => [value],
77
+ }));
78
+
79
+ vi.mock('../finance-route-context', () => ({
80
+ useFinanceHref: () => (path: string) => path,
81
+ }));
82
+
83
+ vi.mock('../shared/use-finance-confidential-visibility', () => ({
84
+ useFinanceConfidentialVisibility: () => ({ isConfidential: false }),
85
+ }));
86
+
87
+ vi.mock('./components/invoice-blocked-state', () => ({
88
+ InvoiceBlockedState: () => null,
89
+ }));
90
+
91
+ vi.mock('./components/invoice-checkout-summary', () => ({
92
+ InvoiceCheckoutSummary: () => null,
93
+ }));
94
+
95
+ vi.mock('./components/invoice-content-editor', () => ({
96
+ InvoiceContentEditor: () => null,
97
+ }));
98
+
99
+ vi.mock('./components/invoice-customer-select-card', () => ({
100
+ InvoiceCustomerSelectCard: () => null,
101
+ }));
102
+
103
+ vi.mock('./components/invoice-payment-settings', () => ({
104
+ InvoicePaymentSettings: (props: unknown) => {
105
+ testState.InvoicePaymentSettings(props);
106
+ return null;
107
+ },
108
+ }));
109
+
110
+ vi.mock('./components/invoice-products-permission-warning', () => ({
111
+ InvoiceProductsPermissionWarning: () => null,
112
+ isPermissionRequestError: () => false,
113
+ }));
114
+
115
+ vi.mock('./components/subscription-attendance-summary', () => ({
116
+ SubscriptionAttendanceSummary: () => null,
117
+ }));
118
+
119
+ vi.mock('./components/subscription-group-selector', () => ({
120
+ SubscriptionGroupSelector: () => null,
121
+ }));
122
+
123
+ vi.mock('./components/subscription-prepaid-controls', () => ({
124
+ SubscriptionPrepaidControls: () => null,
125
+ }));
126
+
127
+ vi.mock('./create-promotion-dialog', () => ({
128
+ CreatePromotionDialog: () => null,
129
+ }));
130
+
131
+ vi.mock('./product-selection', () => ({
132
+ ProductSelection: (props: {
133
+ onSelectedProductsChange: (
134
+ products: typeof testState.selectedProducts
135
+ ) => void;
136
+ }) => {
137
+ if (!testState.productSelectionInjected) {
138
+ testState.productSelectionInjected = true;
139
+ queueMicrotask(() =>
140
+ props.onSelectedProductsChange(testState.selectedProducts)
141
+ );
142
+ }
143
+ return null;
144
+ },
145
+ }));
146
+
147
+ vi.mock('./hooks', () => ({
148
+ useAvailablePromotions: () => ({ data: [] }),
149
+ useCategories: (wsId: string, options?: unknown) => {
150
+ testState.useCategories(wsId, options);
151
+ return { data: testState.categories };
152
+ },
153
+ useInvoiceAttendanceConfig: () => ({ data: true }),
154
+ useInvoiceBlockedGroups: () => ({ data: [] }),
155
+ useInvoiceCustomerSearch: () => ({
156
+ customers: [],
157
+ error: null,
158
+ fetchNextPage: vi.fn(),
159
+ hasNextPage: false,
160
+ isFetching: false,
161
+ isFetchingNextPage: false,
162
+ isLoading: false,
163
+ selectedUser: { id: testState.userId, display_name: 'Customer' },
164
+ }),
165
+ useMultiGroupProducts: () => ({
166
+ data: [],
167
+ error: null,
168
+ isLoading: false,
169
+ }),
170
+ useProducts: () => ({
171
+ data: testState.products,
172
+ error: null,
173
+ isLoading: false,
174
+ }),
175
+ useSubscriptionInvoiceContext: () => ({
176
+ data: { attendance: [], latestInvoices: [] },
177
+ error: null,
178
+ isLoading: false,
179
+ }),
180
+ useUserGroups: () => ({
181
+ data: [
182
+ {
183
+ workspace_user_groups: {
184
+ ending_date: null,
185
+ id: 'group-1',
186
+ name: 'Group 1',
187
+ sessions: ['2026-07-05'],
188
+ starting_date: '2026-07-01',
189
+ },
190
+ },
191
+ ],
192
+ isLoading: false,
193
+ }),
194
+ useUserLinkedPromotions: () => ({ data: [] }),
195
+ useUserReferralDiscounts: () => ({ data: [] }),
196
+ useWallets: (wsId: string, options?: unknown) => {
197
+ testState.useWallets(wsId, options);
198
+ return { data: testState.wallets };
199
+ },
200
+ }));
201
+
202
+ vi.mock('./hooks/use-best-promotion-selection', () => ({
203
+ useBestPromotionSelection: () => undefined,
204
+ }));
205
+
206
+ vi.mock('./hooks/use-invoice-rounding', () => ({
207
+ useInvoiceRounding: () => ({
208
+ resetRounding: vi.fn(),
209
+ roundDown: vi.fn(),
210
+ roundUp: vi.fn(),
211
+ roundedTotal: 100,
212
+ }),
213
+ }));
214
+
215
+ vi.mock('./hooks/use-invoice-subtotal', () => ({
216
+ useInvoiceSubtotal: () => 100,
217
+ }));
218
+
219
+ vi.mock('./hooks/use-subscription-auto-selection', () => ({
220
+ useSubscriptionAutoSelection: () => undefined,
221
+ }));
222
+
223
+ vi.mock('./hooks/use-subscription-invoice-content', () => ({
224
+ useSubscriptionInvoiceContent: () => undefined,
225
+ }));
226
+
227
+ function createQueryClient() {
228
+ return new QueryClient({
229
+ defaultOptions: {
230
+ queries: {
231
+ retry: false,
232
+ },
233
+ },
234
+ });
235
+ }
236
+
237
+ function Wrapper({ children }: PropsWithChildren) {
238
+ return (
239
+ <QueryClientProvider client={createQueryClient()}>
240
+ {children}
241
+ </QueryClientProvider>
242
+ );
243
+ }
244
+
245
+ function renderSubscriptionInvoice(
246
+ props: Partial<ComponentProps<typeof SubscriptionInvoice>> = {}
247
+ ) {
248
+ return render(
249
+ <SubscriptionInvoice
250
+ wsId="ws-1"
251
+ createMultipleInvoices={false}
252
+ defaultCategoryId="category-default"
253
+ defaultCurrency="VND"
254
+ defaultWalletId="wallet-default"
255
+ workspaceTimezone="Asia/Ho_Chi_Minh"
256
+ {...props}
257
+ />,
258
+ { wrapper: Wrapper }
259
+ );
260
+ }
261
+
262
+ describe('SubscriptionInvoice checkout defaults', () => {
263
+ beforeEach(() => {
264
+ testState.InvoicePaymentSettings.mockClear();
265
+ testState.useCategories.mockClear();
266
+ testState.useWallets.mockClear();
267
+ testState.productSelectionInjected = false;
268
+ testState.products[0]!.finance_category_id = null;
269
+ testState.wallets = [];
270
+ testState.categories = [];
271
+ });
272
+
273
+ it('preloads payment options from defaults and keeps default IDs while options load', async () => {
274
+ const { rerender } = renderSubscriptionInvoice();
275
+
276
+ expect(testState.useWallets).toHaveBeenCalledWith('ws-1', {
277
+ enabled: true,
278
+ });
279
+ expect(testState.useCategories).toHaveBeenCalledWith('ws-1', {
280
+ enabled: true,
281
+ });
282
+
283
+ await waitFor(() =>
284
+ expect(testState.InvoicePaymentSettings).toHaveBeenLastCalledWith(
285
+ expect.objectContaining({
286
+ categories: [],
287
+ selectedCategoryId: 'category-default',
288
+ selectedWalletId: 'wallet-default',
289
+ wallets: [],
290
+ })
291
+ )
292
+ );
293
+
294
+ testState.wallets = [
295
+ {
296
+ currency: 'VND',
297
+ id: 'wallet-default',
298
+ name: 'Default wallet',
299
+ type: 'STANDARD',
300
+ },
301
+ ];
302
+ testState.categories = [
303
+ {
304
+ id: 'category-default',
305
+ name: 'Default category',
306
+ },
307
+ ];
308
+
309
+ rerender(
310
+ <SubscriptionInvoice
311
+ wsId="ws-1"
312
+ createMultipleInvoices={false}
313
+ defaultCategoryId="category-default"
314
+ defaultCurrency="VND"
315
+ defaultWalletId="wallet-default"
316
+ workspaceTimezone="Asia/Ho_Chi_Minh"
317
+ />
318
+ );
319
+
320
+ await waitFor(() =>
321
+ expect(testState.InvoicePaymentSettings).toHaveBeenLastCalledWith(
322
+ expect.objectContaining({
323
+ categories: testState.categories,
324
+ selectedCategoryId: 'category-default',
325
+ selectedWalletId: 'wallet-default',
326
+ wallets: testState.wallets,
327
+ })
328
+ )
329
+ );
330
+ });
331
+ });
@@ -37,6 +37,7 @@ import {
37
37
  } from './components/invoice-products-permission-warning';
38
38
  import { SubscriptionAttendanceSummary } from './components/subscription-attendance-summary';
39
39
  import { SubscriptionGroupSelector } from './components/subscription-group-selector';
40
+ import { SubscriptionInvoiceProposal } from './components/subscription-invoice-proposal';
40
41
  import { SubscriptionPrepaidControls } from './components/subscription-prepaid-controls';
41
42
  import { CreatePromotionDialog } from './create-promotion-dialog';
42
43
  import type { AvailablePromotion } from './hooks';
@@ -71,23 +72,27 @@ import {
71
72
  getBillableAttendanceRecordsInRange,
72
73
  getBillableSessionsForGroupsInRange,
73
74
  getCoverageValidUntilMonthValue,
75
+ getCurrentBillingDate,
76
+ getCurrentMonthValue,
74
77
  getGroupsDateRange,
75
- getLinkedFinanceCategorySelection,
76
78
  getMonthStartDate,
77
79
  getSubscriptionAttendanceDisplayData,
78
80
  isSubscriptionRangeFullyPaidForGroups,
79
81
  PREPAID_MONTH_OPTION_HORIZON,
82
+ resolveSubscriptionInvoiceCategoryId,
80
83
  } from './utils';
81
84
 
82
85
  interface Props {
83
86
  wsId: string;
84
- prefillAmount?: number | null; // Total attendance days to prefill product quantities
87
+ prefillQuantity?: number | null;
88
+ suggestedTotal?: number | null;
85
89
  createMultipleInvoices: boolean;
86
90
  printAfterCreate?: boolean;
87
91
  downloadImageAfterCreate?: boolean;
88
92
  defaultWalletId?: string;
89
93
  defaultCategoryId?: string;
90
94
  defaultCurrency?: string;
95
+ workspaceTimezone?: string | null;
91
96
  canChangeFinanceWallets?: boolean;
92
97
  canSetFinanceWalletsOnCreate?: boolean;
93
98
  canReadInvoiceProducts?: boolean;
@@ -98,13 +103,15 @@ interface Props {
98
103
 
99
104
  export function SubscriptionInvoice({
100
105
  wsId,
101
- prefillAmount,
106
+ prefillQuantity,
107
+ suggestedTotal,
102
108
  createMultipleInvoices,
103
109
  printAfterCreate = false,
104
110
  downloadImageAfterCreate = false,
105
111
  defaultWalletId,
106
112
  defaultCategoryId,
107
113
  defaultCurrency: rawDefaultCurrency = 'USD',
114
+ workspaceTimezone,
108
115
  canChangeFinanceWallets = true,
109
116
  canSetFinanceWalletsOnCreate = true,
110
117
  canReadInvoiceProducts = true,
@@ -133,7 +140,7 @@ export function SubscriptionInvoice({
133
140
  })
134
141
  );
135
142
  const [selectedMonth, setSelectedMonth] = useQueryState('month', {
136
- defaultValue: formatMonthValue(new Date()),
143
+ defaultValue: getCurrentMonthValue(workspaceTimezone),
137
144
  shallow: true,
138
145
  });
139
146
  const [prepaidMonthCount, setPrepaidMonthCount] = useState(1);
@@ -144,6 +151,8 @@ export function SubscriptionInvoice({
144
151
  useState<SelectedProductItem[]>([]);
145
152
  const hasSelectedGroups = selectedGroupIds.length > 0;
146
153
  const hasSelectedProducts = subscriptionSelectedProducts.length > 0;
154
+ const shouldPreloadPaymentSettings =
155
+ hasSelectedProducts || Boolean(defaultWalletId || defaultCategoryId);
147
156
 
148
157
  const updateSearchParam = useCallback(
149
158
  (key: string, value: string) => {
@@ -197,10 +206,10 @@ export function SubscriptionInvoice({
197
206
  { enabled: hasSelectedProducts }
198
207
  );
199
208
  const { data: wallets = [] } = useWallets(wsId, {
200
- enabled: hasSelectedProducts,
209
+ enabled: shouldPreloadPaymentSettings,
201
210
  });
202
211
  const { data: categories = [] } = useCategories(wsId, {
203
- enabled: hasSelectedProducts,
212
+ enabled: shouldPreloadPaymentSettings,
204
213
  });
205
214
 
206
215
  // Blocked groups check
@@ -236,9 +245,7 @@ export function SubscriptionInvoice({
236
245
  ) : null;
237
246
 
238
247
  useEffect(() => {
239
- if (defaultWalletId) {
240
- setSelectedWalletId(defaultWalletId);
241
- }
248
+ setSelectedWalletId(defaultWalletId || '');
242
249
  }, [defaultWalletId]);
243
250
 
244
251
  const [selectedPromotionId, setSelectedPromotionId] =
@@ -286,9 +293,10 @@ export function SubscriptionInvoice({
286
293
  [],
287
294
  locale,
288
295
  selectedMonth,
289
- PREPAID_MONTH_OPTION_HORIZON
296
+ PREPAID_MONTH_OPTION_HORIZON,
297
+ { workspaceTimezone }
290
298
  ),
291
- [locale, selectedGroupIds, selectedMonth, userGroups]
299
+ [locale, selectedGroupIds, selectedMonth, userGroups, workspaceTimezone]
292
300
  );
293
301
 
294
302
  const effectiveSelectedMonth = useMemo(() => {
@@ -309,7 +317,7 @@ export function SubscriptionInvoice({
309
317
  return selectedMonth;
310
318
  }
311
319
 
312
- const now = new Date();
320
+ const now = getCurrentBillingDate(workspaceTimezone);
313
321
  let defaultMonth: Date;
314
322
 
315
323
  if (!latestEnd || (now >= earliestStart && now <= latestEnd)) {
@@ -321,7 +329,13 @@ export function SubscriptionInvoice({
321
329
  }
322
330
 
323
331
  return formatMonthValue(defaultMonth);
324
- }, [availableMonthOptions, selectedGroupIds, selectedMonth, userGroups]);
332
+ }, [
333
+ availableMonthOptions,
334
+ selectedGroupIds,
335
+ selectedMonth,
336
+ userGroups,
337
+ workspaceTimezone,
338
+ ]);
325
339
 
326
340
  const {
327
341
  data: subscriptionInvoiceContext,
@@ -538,7 +552,7 @@ export function SubscriptionInvoice({
538
552
  enabled: true,
539
553
  selectedGroupIds,
540
554
  selectedMonth: effectiveSelectedMonth,
541
- prefillAmount,
555
+ prefillAmount: prefillQuantity,
542
556
  groupProducts,
543
557
  products,
544
558
  userGroups,
@@ -547,6 +561,7 @@ export function SubscriptionInvoice({
547
561
  latestSubscriptionInvoices,
548
562
  onSelectedProductsChange: setSubscriptionSelectedProducts,
549
563
  prepaidMonthCount,
564
+ workspaceTimezone,
550
565
  });
551
566
 
552
567
  useSubscriptionInvoiceContent({
@@ -566,21 +581,18 @@ export function SubscriptionInvoice({
566
581
  });
567
582
 
568
583
  const subtotal = useInvoiceSubtotal(subscriptionSelectedProducts);
569
- const linkedFinanceCategorySelection = useMemo(
570
- () => getLinkedFinanceCategorySelection(subscriptionSelectedProducts),
571
- [subscriptionSelectedProducts]
584
+ const resolvedSubscriptionCategoryId = useMemo(
585
+ () =>
586
+ resolveSubscriptionInvoiceCategoryId({
587
+ defaultCategoryId,
588
+ items: subscriptionSelectedProducts,
589
+ }),
590
+ [defaultCategoryId, subscriptionSelectedProducts]
572
591
  );
573
592
 
574
593
  useEffect(() => {
575
- if (linkedFinanceCategorySelection.categoryId) {
576
- setSelectedCategoryId(linkedFinanceCategorySelection.categoryId);
577
- return;
578
- }
579
-
580
- if (linkedFinanceCategorySelection.hasMixedCategories) {
581
- setSelectedCategoryId('');
582
- }
583
- }, [linkedFinanceCategorySelection]);
594
+ setSelectedCategoryId(resolvedSubscriptionCategoryId);
595
+ }, [resolvedSubscriptionCategoryId]);
584
596
 
585
597
  useBestPromotionSelection({
586
598
  enabled: true,
@@ -677,7 +689,8 @@ export function SubscriptionInvoice({
677
689
  latestSubscriptionInvoices,
678
690
  locale,
679
691
  effectiveSelectedMonth,
680
- PREPAID_MONTH_OPTION_HORIZON
692
+ PREPAID_MONTH_OPTION_HORIZON,
693
+ { workspaceTimezone }
681
694
  ),
682
695
  [
683
696
  effectiveSelectedMonth,
@@ -685,6 +698,7 @@ export function SubscriptionInvoice({
685
698
  selectedGroupIds,
686
699
  latestSubscriptionInvoices,
687
700
  locale,
701
+ workspaceTimezone,
688
702
  ]
689
703
  );
690
704
 
@@ -732,7 +746,7 @@ export function SubscriptionInvoice({
732
746
  if (lastValidatedKeyRef.current === monthValidationKey) return;
733
747
  lastValidatedKeyRef.current = monthValidationKey;
734
748
 
735
- const now = new Date();
749
+ const now = getCurrentBillingDate(workspaceTimezone);
736
750
  let defaultMonth: Date;
737
751
 
738
752
  if (now >= earliestStart && now <= latestEnd) defaultMonth = now;
@@ -754,6 +768,7 @@ export function SubscriptionInvoice({
754
768
  selectedMonth,
755
769
  updateSearchParam,
756
770
  userGroups,
771
+ workspaceTimezone,
757
772
  ]);
758
773
 
759
774
  const navigateMonth = (direction: 'prev' | 'next') => {
@@ -1020,6 +1035,16 @@ export function SubscriptionInvoice({
1020
1035
  <InvoiceBlockedState type="subscription" />
1021
1036
  ) : (
1022
1037
  <>
1038
+ {suggestedTotal != null && (
1039
+ <SubscriptionInvoiceProposal
1040
+ billableQuantity={prefillQuantity}
1041
+ currency={defaultCurrency}
1042
+ currentTotal={subscriptionRoundedTotal}
1043
+ hasSelectedProducts={hasSelectedProducts}
1044
+ suggestedTotal={suggestedTotal}
1045
+ />
1046
+ )}
1047
+
1023
1048
  {selectedGroupIds.length > 0 && effectiveSelectedMonth && (
1024
1049
  <SubscriptionAttendanceSummary
1025
1050
  selectedGroupIds={selectedGroupIds}
@@ -6,11 +6,14 @@ import {
6
6
  getBillableQuantityMapForGroupsRange,
7
7
  getBillableSessionsForGroups,
8
8
  getCoverageMonths,
9
+ getCurrentMonthValue,
9
10
  getLinkedFinanceCategorySelection,
10
11
  getSubscriptionAttendanceDisplayData,
11
12
  getSubscriptionCoverageInvoiceForGroup,
12
13
  isSubscriptionMonthPaidForGroup,
13
14
  isSubscriptionRangeFullyPaidForGroups,
15
+ resolveBillingTimezone,
16
+ resolveSubscriptionInvoiceCategoryId,
14
17
  type UserGroup,
15
18
  } from './utils';
16
19
 
@@ -169,6 +172,19 @@ describe('subscription invoice coverage', () => {
169
172
  ).toBe(false);
170
173
  });
171
174
 
175
+ it('treats timestamp valid_until values as civil invoice months', () => {
176
+ const latestInvoices = [
177
+ { group_id: groupId, valid_until: '2026-07-01T00:00:00.000Z' },
178
+ ];
179
+
180
+ expect(
181
+ isSubscriptionMonthPaidForGroup(groupId, '2026-06', latestInvoices)
182
+ ).toBe(true);
183
+ expect(
184
+ isSubscriptionMonthPaidForGroup(groupId, '2026-07', latestInvoices)
185
+ ).toBe(false);
186
+ });
187
+
172
188
  it('treats null and invalid valid_until values as unpaid', () => {
173
189
  expect(
174
190
  isSubscriptionMonthPaidForGroup(groupId, '2026-05', [
@@ -265,6 +281,50 @@ describe('subscription invoice coverage', () => {
265
281
  })
266
282
  ).toEqual({ [groupId]: 2 });
267
283
  });
284
+
285
+ it('uses workspace timezone, not browser timezone, for current-month recommendations', () => {
286
+ const groups = [
287
+ {
288
+ workspace_user_groups: {
289
+ id: groupId,
290
+ name: 'Math 7',
291
+ sessions: ['2026-07-05'],
292
+ starting_date: '2026-07-01',
293
+ ending_date: null,
294
+ } as NonNullable<UserGroup['workspace_user_groups']>,
295
+ },
296
+ ] satisfies UserGroup[];
297
+ const now = new Date('2026-06-30T18:00:00.000Z');
298
+
299
+ expect(getCurrentMonthValue('UTC', now)).toBe('2026-06');
300
+ expect(getCurrentMonthValue('Asia/Ho_Chi_Minh', now)).toBe('2026-07');
301
+ expect(getCurrentMonthValue('America/Los_Angeles', now)).toBe('2026-06');
302
+ expect(resolveBillingTimezone('auto')).toBe('UTC');
303
+ expect(resolveBillingTimezone('Not/A_Timezone')).toBe('UTC');
304
+
305
+ expect(
306
+ getBillableQuantityMapForGroupsRange({
307
+ groupIds: [groupId],
308
+ now,
309
+ selectedMonth: '2026-07',
310
+ useAttendanceBased: true,
311
+ userAttendance: [],
312
+ userGroups: groups,
313
+ workspaceTimezone: 'Asia/Ho_Chi_Minh',
314
+ })
315
+ ).toEqual({ [groupId]: 0 });
316
+ expect(
317
+ getBillableQuantityMapForGroupsRange({
318
+ groupIds: [groupId],
319
+ now,
320
+ selectedMonth: '2026-07',
321
+ useAttendanceBased: true,
322
+ userAttendance: [],
323
+ userGroups: groups,
324
+ workspaceTimezone: 'America/Los_Angeles',
325
+ })
326
+ ).toEqual({ [groupId]: 1 });
327
+ });
268
328
  });
269
329
 
270
330
  describe('linked finance category selection', () => {
@@ -294,4 +354,28 @@ describe('linked finance category selection', () => {
294
354
  hasSingleCategory: false,
295
355
  });
296
356
  });
357
+
358
+ it('resolves subscription checkout category precedence', () => {
359
+ expect(
360
+ resolveSubscriptionInvoiceCategoryId({
361
+ defaultCategoryId: 'category-default',
362
+ items: [{ product: { finance_category_id: 'category-linked' } }],
363
+ })
364
+ ).toBe('category-linked');
365
+ expect(
366
+ resolveSubscriptionInvoiceCategoryId({
367
+ defaultCategoryId: 'category-default',
368
+ items: [
369
+ { product: { finance_category_id: 'category-a' } },
370
+ { product: { finance_category_id: 'category-b' } },
371
+ ],
372
+ })
373
+ ).toBe('');
374
+ expect(
375
+ resolveSubscriptionInvoiceCategoryId({
376
+ defaultCategoryId: 'category-default',
377
+ items: [{ product: { finance_category_id: null } }],
378
+ })
379
+ ).toBe('category-default');
380
+ });
297
381
  });