@tuturuuu/ui 0.14.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 (86) hide show
  1. package/CHANGELOG.md +78 -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-dialog-shell.tsx +18 -12
  12. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  13. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  14. package/src/components/ui/custom/statistic-card.tsx +104 -0
  15. package/src/components/ui/custom/structure.test.tsx +29 -0
  16. package/src/components/ui/custom/structure.tsx +103 -88
  17. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  18. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  23. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  24. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  25. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  26. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  27. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  28. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  29. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  30. package/src/components/ui/finance/invoices/utils.ts +56 -134
  31. package/src/components/ui/resizable.test.ts +15 -0
  32. package/src/components/ui/resizable.tsx +28 -6
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
  36. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  37. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  38. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  39. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  40. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  41. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  45. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  46. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  47. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  51. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  52. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  53. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  54. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  55. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  56. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  57. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  58. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  59. package/src/components/ui/tu-do/projects/types.ts +4 -1
  60. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  61. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  62. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  63. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  64. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  65. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  66. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  69. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  75. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  76. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  77. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  78. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  79. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  80. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  81. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  82. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  83. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  84. package/src/hooks/task-actions-personal-external.ts +12 -6
  85. package/src/hooks/use-notifications.ts +14 -2
  86. package/vitest.config.ts +4 -0
@@ -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
  });
@@ -1,4 +1,30 @@
1
1
  import type { Database } from '@tuturuuu/types';
2
+ import {
3
+ formatMonthLabel,
4
+ formatMonthValue,
5
+ getCoverageMonths,
6
+ getCurrentMonthStartDate,
7
+ getMonthStartDate,
8
+ parseLocalCalendarDate,
9
+ } from './civil-month';
10
+
11
+ export {
12
+ addMonthsToMonthValue,
13
+ formatCoverageRangeLabel,
14
+ formatMonthLabel,
15
+ formatMonthValue,
16
+ getCoverageEndMonthValue,
17
+ getCoverageMonths,
18
+ getCoverageValidUntilMonthValue,
19
+ getCurrentBillingDate,
20
+ getCurrentMonthStartDate,
21
+ getCurrentMonthValue,
22
+ getMonthStartDate,
23
+ MAX_PREPAID_MONTH_COUNT,
24
+ PREPAID_MONTH_OPTION_HORIZON,
25
+ parseLocalCalendarDate,
26
+ resolveBillingTimezone,
27
+ } from './civil-month';
2
28
 
3
29
  type AttendanceRecord = {
4
30
  status: string;
@@ -67,134 +93,25 @@ export const getLinkedFinanceCategorySelection = (
67
93
  };
68
94
  };
69
95
 
70
- const MONTH_VALUE_PATTERN = /^(\d{4})-(\d{2})$/;
71
- const DATE_VALUE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
72
- export const MAX_PREPAID_MONTH_COUNT = 12;
73
- export const PREPAID_MONTH_OPTION_HORIZON = 12;
74
-
75
- export const parseLocalCalendarDate = (
76
- value: string | Date | null | undefined
77
- ): Date => {
78
- if (!value) {
79
- return new Date(Number.NaN);
80
- }
81
-
82
- if (value instanceof Date) {
83
- return new Date(value.getTime());
84
- }
85
-
86
- const monthMatch = MONTH_VALUE_PATTERN.exec(value);
87
- if (monthMatch) {
88
- const [, year, month] = monthMatch;
89
- return new Date(Number(year), Number(month) - 1, 1);
90
- }
91
-
92
- const dateMatch = DATE_VALUE_PATTERN.exec(value);
93
- if (dateMatch) {
94
- const [, year, month, day] = dateMatch;
95
- return new Date(Number(year), Number(month) - 1, Number(day));
96
- }
97
-
98
- return new Date(value);
99
- };
100
-
101
- export const getMonthStartDate = (
102
- value: string | Date | null | undefined
103
- ): Date => {
104
- const date = parseLocalCalendarDate(value);
105
- if (Number.isNaN(date.getTime())) {
106
- return date;
107
- }
108
-
109
- return new Date(date.getFullYear(), date.getMonth(), 1);
110
- };
111
-
112
- export const formatMonthValue = (date: Date): string =>
113
- `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
114
-
115
- export const formatMonthLabel = (month: string, locale: string): string => {
116
- const date = getMonthStartDate(month);
117
- if (Number.isNaN(date.getTime())) {
118
- return month;
119
- }
120
-
121
- return date.toLocaleDateString(locale, {
122
- year: 'numeric',
123
- month: 'long',
124
- });
125
- };
126
-
127
- export const addMonthsToMonthValue = (
128
- month: string,
129
- monthOffset: number
130
- ): string => {
131
- const date = getMonthStartDate(month);
132
- if (Number.isNaN(date.getTime())) return month;
133
-
134
- date.setMonth(date.getMonth() + monthOffset);
135
- return formatMonthValue(date);
136
- };
137
-
138
- export const normalizePrepaidMonthCount = (
139
- value: number | null | undefined
140
- ): number => {
141
- if (!Number.isFinite(value) || !value) return 1;
142
-
143
- return Math.min(MAX_PREPAID_MONTH_COUNT, Math.max(1, Math.trunc(value)));
144
- };
145
-
146
- export const getCoverageMonths = (
147
- selectedMonth: string,
148
- prepaidMonthCount = 1
149
- ): string[] => {
150
- const monthCount = normalizePrepaidMonthCount(prepaidMonthCount);
151
- const startMonth = getMonthStartDate(selectedMonth);
152
- if (Number.isNaN(startMonth.getTime())) return [];
153
-
154
- return Array.from({ length: monthCount }, (_, index) => {
155
- const date = new Date(startMonth);
156
- date.setMonth(date.getMonth() + index);
157
- return formatMonthValue(date);
158
- });
159
- };
160
-
161
- export const getCoverageEndMonthValue = (
162
- selectedMonth: string,
163
- prepaidMonthCount = 1
164
- ): string => {
165
- const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
166
- return coverageMonths[coverageMonths.length - 1] ?? selectedMonth;
167
- };
168
-
169
- export const getCoverageValidUntilMonthValue = (
170
- selectedMonth: string,
171
- prepaidMonthCount = 1
172
- ): string => addMonthsToMonthValue(selectedMonth, prepaidMonthCount);
173
-
174
- export const formatCoverageRangeLabel = ({
175
- locale,
176
- prepaidMonthCount,
177
- selectedMonth,
96
+ export const resolveSubscriptionInvoiceCategoryId = ({
97
+ defaultCategoryId,
98
+ items,
178
99
  }: {
179
- locale: string;
180
- prepaidMonthCount?: number | null;
181
- selectedMonth: string;
182
- }): string => {
183
- const coverageMonths = getCoverageMonths(
184
- selectedMonth,
185
- prepaidMonthCount ?? 1
186
- );
187
- const firstMonth = coverageMonths[0];
188
- const lastMonth = coverageMonths[coverageMonths.length - 1];
100
+ defaultCategoryId?: string | null;
101
+ items: FinanceCategoryLinkedItem[];
102
+ }) => {
103
+ const linkedFinanceCategorySelection =
104
+ getLinkedFinanceCategorySelection(items);
105
+
106
+ if (linkedFinanceCategorySelection.hasSingleCategory) {
107
+ return linkedFinanceCategorySelection.categoryId ?? '';
108
+ }
189
109
 
190
- if (!firstMonth || !lastMonth || firstMonth === lastMonth) {
191
- return formatMonthLabel(selectedMonth, locale);
110
+ if (linkedFinanceCategorySelection.hasMixedCategories) {
111
+ return '';
192
112
  }
193
113
 
194
- return `${formatMonthLabel(firstMonth, locale)} - ${formatMonthLabel(
195
- lastMonth,
196
- locale
197
- )}`;
114
+ return defaultCategoryId ?? '';
198
115
  };
199
116
 
200
117
  const getComparableTimestamp = (value: string | null | undefined): number => {
@@ -532,7 +449,6 @@ export const getSessionsUntilMonth = (
532
449
  }
533
450
  };
534
451
 
535
- /** Date range (earliest start, latest end) for selected groups. */
536
452
  export type GroupsDateRange = {
537
453
  earliestStart: Date | null;
538
454
  latestEnd: Date | null;
@@ -583,17 +499,20 @@ export const getAvailableMonths = (
583
499
  latestInvoices: SubscriptionCoverageInvoice[],
584
500
  locale: string,
585
501
  selectedMonthFallback: string | null = null,
586
- futureMonthHorizon = 0
502
+ futureMonthHorizon = 0,
503
+ options: {
504
+ now?: Date;
505
+ workspaceTimezone?: string | null;
506
+ } = {}
587
507
  ): AvailableMonthOption[] => {
588
508
  if (groupIds.length === 0) return [];
589
509
  const { earliestStart, latestEnd } = getGroupsDateRange(userGroups, groupIds);
590
510
  if (!earliestStart) return [];
591
511
 
592
- const currentMonthStart = (() => {
593
- const date = new Date();
594
- date.setDate(1);
595
- return date;
596
- })();
512
+ const currentMonthStart = getCurrentMonthStartDate(
513
+ options.workspaceTimezone,
514
+ options.now
515
+ );
597
516
  const futureHorizonEnd = new Date(currentMonthStart);
598
517
  futureHorizonEnd.setMonth(
599
518
  futureHorizonEnd.getMonth() + Math.max(0, futureMonthHorizon)
@@ -648,6 +567,7 @@ export const getBillableQuantityForGroupRange = ({
648
567
  now = new Date(),
649
568
  prepaidMonthCount = 1,
650
569
  selectedMonth,
570
+ workspaceTimezone,
651
571
  useAttendanceBased,
652
572
  userAttendance,
653
573
  userGroups,
@@ -657,11 +577,12 @@ export const getBillableQuantityForGroupRange = ({
657
577
  now?: Date;
658
578
  prepaidMonthCount?: number;
659
579
  selectedMonth: string;
580
+ workspaceTimezone?: string | null;
660
581
  useAttendanceBased: boolean;
661
582
  userAttendance: AttendanceRecord[];
662
583
  userGroups: UserGroup[];
663
584
  }): number => {
664
- const currentMonthStart = getMonthStartDate(now);
585
+ const currentMonthStart = getCurrentMonthStartDate(workspaceTimezone, now);
665
586
  const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
666
587
 
667
588
  return coverageMonths.reduce((total, month) => {
@@ -708,6 +629,7 @@ export const getBillableQuantityMapForGroupsRange = ({
708
629
  now,
709
630
  prepaidMonthCount = 1,
710
631
  selectedMonth,
632
+ workspaceTimezone,
711
633
  useAttendanceBased,
712
634
  userAttendance,
713
635
  userGroups,
@@ -717,6 +639,7 @@ export const getBillableQuantityMapForGroupsRange = ({
717
639
  now?: Date;
718
640
  prepaidMonthCount?: number;
719
641
  selectedMonth: string;
642
+ workspaceTimezone?: string | null;
720
643
  useAttendanceBased: boolean;
721
644
  userAttendance: AttendanceRecord[];
722
645
  userGroups: UserGroup[];
@@ -730,6 +653,7 @@ export const getBillableQuantityMapForGroupsRange = ({
730
653
  now,
731
654
  prepaidMonthCount,
732
655
  selectedMonth,
656
+ workspaceTimezone,
733
657
  useAttendanceBased,
734
658
  userAttendance,
735
659
  userGroups,
@@ -737,11 +661,9 @@ export const getBillableQuantityMapForGroupsRange = ({
737
661
  ])
738
662
  );
739
663
 
740
- /** Days before valid_until to consider "expiring soon" */
741
664
  const EXPIRING_SOON_DAYS = 14;
742
665
 
743
666
  export type GroupPaymentStatus = 'active' | 'expiringSoon' | 'expired';
744
-
745
667
  export function getGroupPaymentStatus(
746
668
  group: WorkspaceUserGroup | null,
747
669
  latestInvoice:
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { normalizeLegacyPanelSize } from './resizable';
3
+
4
+ describe('normalizeLegacyPanelSize', () => {
5
+ it('preserves the shared wrapper numeric percentage contract', () => {
6
+ expect(normalizeLegacyPanelSize(38)).toBe('38%');
7
+ expect(normalizeLegacyPanelSize(0)).toBe('0%');
8
+ });
9
+
10
+ it('leaves explicitly unitized sizes unchanged', () => {
11
+ expect(normalizeLegacyPanelSize('20rem')).toBe('20rem');
12
+ expect(normalizeLegacyPanelSize('38%')).toBe('38%');
13
+ expect(normalizeLegacyPanelSize(undefined)).toBeUndefined();
14
+ });
15
+ });
@@ -16,6 +16,13 @@ interface ResizablePanelGroupProps
16
16
  onLayout?: (sizes: number[]) => void;
17
17
  }
18
18
 
19
+ export function normalizeLegacyPanelSize(size: number | string | undefined) {
20
+ // react-resizable-panels v3 and the shadcn wrapper treated numeric values as
21
+ // percentages. v4 treats them as pixels, so preserve the shared wrapper's
22
+ // established contract while still allowing explicit CSS units.
23
+ return typeof size === 'number' ? `${size}%` : size;
24
+ }
25
+
19
26
  function ResizablePanelGroup({
20
27
  className,
21
28
  direction,
@@ -26,11 +33,11 @@ function ResizablePanelGroup({
26
33
  <Group
27
34
  data-slot="resizable-panel-group"
28
35
  orientation={direction}
29
- onLayoutChange={
36
+ onLayoutChanged={
30
37
  onLayout
31
38
  ? (layout) => {
32
- // Convert Layout (Record<string, number>) to number[] for backward compatibility
33
- // Layout values are already percentages (0-100)
39
+ // Layout values are flex percentages (0-100). Preserve the
40
+ // wrapper's array callback for existing consumers.
34
41
  const sizes = Object.values(layout);
35
42
  onLayout(sizes);
36
43
  }
@@ -45,8 +52,23 @@ function ResizablePanelGroup({
45
52
  );
46
53
  }
47
54
 
48
- function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
49
- return <Panel data-slot="resizable-panel" {...props} />;
55
+ function ResizablePanel({
56
+ collapsedSize,
57
+ defaultSize,
58
+ maxSize,
59
+ minSize,
60
+ ...props
61
+ }: React.ComponentProps<typeof Panel>) {
62
+ return (
63
+ <Panel
64
+ collapsedSize={normalizeLegacyPanelSize(collapsedSize)}
65
+ data-slot="resizable-panel"
66
+ defaultSize={normalizeLegacyPanelSize(defaultSize)}
67
+ maxSize={normalizeLegacyPanelSize(maxSize)}
68
+ minSize={normalizeLegacyPanelSize(minSize)}
69
+ {...props}
70
+ />
71
+ );
50
72
  }
51
73
 
52
74
  function ResizableHandle({
@@ -60,7 +82,7 @@ function ResizableHandle({
60
82
  <Separator
61
83
  data-slot="resizable-handle"
62
84
  className={cn(
63
- 'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
85
+ 'relative flex w-px cursor-col-resize items-center justify-center bg-border transition-colors after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-primary/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-3 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
64
86
  className
65
87
  )}
66
88
  {...props}