@tuturuuu/ui 0.9.0 → 0.11.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 (123) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  17. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  18. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  21. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  22. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  24. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  25. package/src/components/ui/finance/invoices/utils.ts +240 -7
  26. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  27. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  28. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  29. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  30. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  31. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  33. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  35. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  36. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  37. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  38. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  39. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  40. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  41. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  46. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  50. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  51. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  52. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  53. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  54. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  55. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  56. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  57. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  58. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  59. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  60. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  61. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  62. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  63. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +123 -1
  64. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  65. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  66. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  67. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +58 -0
  68. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  69. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  70. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  71. package/src/components/ui/tu-do/shared/board-client.tsx +16 -4
  72. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  73. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  74. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  75. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  76. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  77. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +58 -4
  78. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  79. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  102. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  103. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  104. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  105. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  106. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  107. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  108. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +390 -34
  109. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  110. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  111. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  112. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  113. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  114. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  115. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  116. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  117. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  118. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
  119. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  120. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  121. package/src/hooks/useBoardPresence.ts +364 -0
  122. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  123. package/src/lib/workspace-actions.ts +2 -6
@@ -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 { SubscriptionPrepaidControls } from './components/subscription-prepaid-controls';
40
41
  import { CreatePromotionDialog } from './create-promotion-dialog';
41
42
  import type { AvailablePromotion } from './hooks';
42
43
  import {
@@ -63,15 +64,19 @@ import { formatInvoiceRecalculationDescription } from './invoice-visibility-form
63
64
  import { ProductSelection } from './product-selection';
64
65
  import type { SelectedProductItem } from './types';
65
66
  import {
67
+ formatCoverageRangeLabel,
68
+ formatMonthLabel,
66
69
  formatMonthValue,
67
70
  getAvailableMonths,
68
- getBillableAttendanceRecords,
69
- getBillableSessionsForGroups,
71
+ getBillableAttendanceRecordsInRange,
72
+ getBillableSessionsForGroupsInRange,
73
+ getCoverageValidUntilMonthValue,
70
74
  getGroupsDateRange,
71
75
  getLinkedFinanceCategorySelection,
72
76
  getMonthStartDate,
73
77
  getSubscriptionAttendanceDisplayData,
74
- isSubscriptionMonthPaidForGroup,
78
+ isSubscriptionRangeFullyPaidForGroups,
79
+ PREPAID_MONTH_OPTION_HORIZON,
75
80
  } from './utils';
76
81
 
77
82
  interface Props {
@@ -131,6 +136,7 @@ export function SubscriptionInvoice({
131
136
  defaultValue: formatMonthValue(new Date()),
132
137
  shallow: true,
133
138
  });
139
+ const [prepaidMonthCount, setPrepaidMonthCount] = useState(1);
134
140
 
135
141
  const [customerSearch, setCustomerSearch] = useState('');
136
142
  const [debouncedCustomerSearch] = useDebounce(customerSearch, 300);
@@ -279,7 +285,8 @@ export function SubscriptionInvoice({
279
285
  selectedGroupIds,
280
286
  [],
281
287
  locale,
282
- selectedMonth
288
+ selectedMonth,
289
+ PREPAID_MONTH_OPTION_HORIZON
283
290
  ),
284
291
  [locale, selectedGroupIds, selectedMonth, userGroups]
285
292
  );
@@ -324,7 +331,8 @@ export function SubscriptionInvoice({
324
331
  wsId,
325
332
  selectedUserId,
326
333
  selectedGroupIds,
327
- effectiveSelectedMonth
334
+ effectiveSelectedMonth,
335
+ prepaidMonthCount
328
336
  );
329
337
 
330
338
  const userAttendance = subscriptionInvoiceContext?.attendance ?? [];
@@ -335,35 +343,62 @@ export function SubscriptionInvoice({
335
343
  ? subscriptionInvoiceContextError
336
344
  : null;
337
345
 
338
- const isSelectedMonthPaid = useMemo(() => {
346
+ const coverageRangeLabel = useMemo(
347
+ () =>
348
+ formatCoverageRangeLabel({
349
+ locale,
350
+ prepaidMonthCount,
351
+ selectedMonth: effectiveSelectedMonth,
352
+ }),
353
+ [effectiveSelectedMonth, locale, prepaidMonthCount]
354
+ );
355
+ const coverageValidUntilLabel = useMemo(
356
+ () =>
357
+ formatMonthLabel(
358
+ getCoverageValidUntilMonthValue(
359
+ effectiveSelectedMonth,
360
+ prepaidMonthCount
361
+ ),
362
+ locale
363
+ ),
364
+ [effectiveSelectedMonth, locale, prepaidMonthCount]
365
+ );
366
+
367
+ const isSelectedRangePaid = useMemo(() => {
339
368
  if (selectedGroupIds.length === 0 || !effectiveSelectedMonth) return false;
340
369
 
341
370
  const selectedMonthStart = getMonthStartDate(effectiveSelectedMonth);
342
371
 
343
- // A month is considered paid ONLY if ALL selected groups have paid for it.
344
- // If ANY selected group has not paid, we allow creating an invoice.
372
+ // A range is paid only if all selected groups have coverage for every month.
373
+ // If any selected group has an unpaid month, invoice creation stays enabled.
345
374
  if (Number.isNaN(selectedMonthStart.getTime())) return false;
346
375
 
347
- return selectedGroupIds.every((groupId) =>
348
- isSubscriptionMonthPaidForGroup(
349
- groupId,
350
- effectiveSelectedMonth,
351
- latestSubscriptionInvoices
352
- )
376
+ return isSubscriptionRangeFullyPaidForGroups(
377
+ selectedGroupIds,
378
+ effectiveSelectedMonth,
379
+ prepaidMonthCount,
380
+ latestSubscriptionInvoices
353
381
  );
354
- }, [effectiveSelectedMonth, latestSubscriptionInvoices, selectedGroupIds]);
382
+ }, [
383
+ effectiveSelectedMonth,
384
+ latestSubscriptionInvoices,
385
+ prepaidMonthCount,
386
+ selectedGroupIds,
387
+ ]);
355
388
 
356
389
  const billableAttendance = useMemo(
357
390
  () =>
358
- getBillableAttendanceRecords(
391
+ getBillableAttendanceRecordsInRange(
359
392
  userAttendance,
360
393
  selectedGroupIds,
361
394
  effectiveSelectedMonth,
395
+ prepaidMonthCount,
362
396
  latestSubscriptionInvoices
363
397
  ),
364
398
  [
365
399
  effectiveSelectedMonth,
366
400
  latestSubscriptionInvoices,
401
+ prepaidMonthCount,
367
402
  selectedGroupIds,
368
403
  userAttendance,
369
404
  ]
@@ -371,15 +406,17 @@ export function SubscriptionInvoice({
371
406
 
372
407
  const billableSessions = useMemo(
373
408
  () =>
374
- getBillableSessionsForGroups(
409
+ getBillableSessionsForGroupsInRange(
375
410
  userGroups,
376
411
  selectedGroupIds,
377
412
  effectiveSelectedMonth,
413
+ prepaidMonthCount,
378
414
  latestSubscriptionInvoices
379
415
  ),
380
416
  [
381
417
  effectiveSelectedMonth,
382
418
  latestSubscriptionInvoices,
419
+ prepaidMonthCount,
383
420
  selectedGroupIds,
384
421
  userGroups,
385
422
  ]
@@ -387,22 +424,29 @@ export function SubscriptionInvoice({
387
424
 
388
425
  const monthlyAttendance = useMemo(
389
426
  () =>
390
- getBillableAttendanceRecords(
427
+ getBillableAttendanceRecordsInRange(
391
428
  userAttendance,
392
429
  selectedGroupIds,
393
- effectiveSelectedMonth
430
+ effectiveSelectedMonth,
431
+ prepaidMonthCount
394
432
  ),
395
- [effectiveSelectedMonth, selectedGroupIds, userAttendance]
433
+ [
434
+ effectiveSelectedMonth,
435
+ prepaidMonthCount,
436
+ selectedGroupIds,
437
+ userAttendance,
438
+ ]
396
439
  );
397
440
 
398
441
  const monthlySessions = useMemo(
399
442
  () =>
400
- getBillableSessionsForGroups(
443
+ getBillableSessionsForGroupsInRange(
401
444
  userGroups,
402
445
  selectedGroupIds,
403
- effectiveSelectedMonth
446
+ effectiveSelectedMonth,
447
+ prepaidMonthCount
404
448
  ),
405
- [effectiveSelectedMonth, selectedGroupIds, userGroups]
449
+ [effectiveSelectedMonth, prepaidMonthCount, selectedGroupIds, userGroups]
406
450
  );
407
451
 
408
452
  const {
@@ -414,7 +458,7 @@ export function SubscriptionInvoice({
414
458
  } = useMemo(
415
459
  () =>
416
460
  getSubscriptionAttendanceDisplayData({
417
- isSelectedMonthPaid,
461
+ isSelectedMonthPaid: isSelectedRangePaid,
418
462
  billableAttendance,
419
463
  billableSessions,
420
464
  monthlyAttendance,
@@ -423,7 +467,7 @@ export function SubscriptionInvoice({
423
467
  [
424
468
  billableAttendance,
425
469
  billableSessions,
426
- isSelectedMonthPaid,
470
+ isSelectedRangePaid,
427
471
  monthlyAttendance,
428
472
  monthlySessions,
429
473
  ]
@@ -502,6 +546,7 @@ export function SubscriptionInvoice({
502
546
  userAttendance: billableAttendance,
503
547
  latestSubscriptionInvoices,
504
548
  onSelectedProductsChange: setSubscriptionSelectedProducts,
549
+ prepaidMonthCount,
505
550
  });
506
551
 
507
552
  useSubscriptionInvoiceContent({
@@ -513,10 +558,11 @@ export function SubscriptionInvoice({
513
558
  subscriptionSelectedProducts,
514
559
  userAttendance: billableAttendance,
515
560
  latestSubscriptionInvoices,
516
- isSelectedMonthPaid,
561
+ isSelectedMonthPaid: isSelectedRangePaid,
517
562
  locale,
518
563
  onContentChange: setInvoiceContent,
519
564
  onNotesChange: setInvoiceNotes,
565
+ prepaidMonthCount,
520
566
  });
521
567
 
522
568
  const subtotal = useInvoiceSubtotal(subscriptionSelectedProducts);
@@ -582,6 +628,7 @@ export function SubscriptionInvoice({
582
628
  setSelectedCategoryId(defaultCategoryId || '');
583
629
  setSelectedGroupIds(null);
584
630
  setSelectedMonth(null);
631
+ setPrepaidMonthCount(1);
585
632
  }
586
633
  }, [
587
634
  selectedUserId,
@@ -629,7 +676,8 @@ export function SubscriptionInvoice({
629
676
  selectedGroupIds,
630
677
  latestSubscriptionInvoices,
631
678
  locale,
632
- effectiveSelectedMonth
679
+ effectiveSelectedMonth,
680
+ PREPAID_MONTH_OPTION_HORIZON
633
681
  ),
634
682
  [
635
683
  effectiveSelectedMonth,
@@ -778,6 +826,7 @@ export function SubscriptionInvoice({
778
826
  frontend_subtotal: subtotal,
779
827
  frontend_discount_amount: discountAmount,
780
828
  frontend_total: subscriptionRoundedTotal,
829
+ prepaid_month_count: prepaidMonthCount,
781
830
  };
782
831
 
783
832
  const result = await createSubscriptionInvoiceWithInternalApi(wsId, {
@@ -824,6 +873,7 @@ export function SubscriptionInvoice({
824
873
  setSelectedWalletId(defaultWalletId || '');
825
874
  setSelectedCategoryId(defaultCategoryId || '');
826
875
  setSelectedGroupIds(null);
876
+ setPrepaidMonthCount(1);
827
877
  setCustomerSearch('');
828
878
  }
829
879
  } catch (error) {
@@ -905,7 +955,16 @@ export function SubscriptionInvoice({
905
955
  />
906
956
  )}
907
957
 
908
- {selectedGroupIds.length > 0 && !isSelectedMonthPaid && !isBlocked && (
958
+ {selectedGroupIds.length > 0 && (
959
+ <SubscriptionPrepaidControls
960
+ coverageRangeLabel={coverageRangeLabel}
961
+ prepaidMonthCount={prepaidMonthCount}
962
+ validUntilLabel={coverageValidUntilLabel}
963
+ onPrepaidMonthCountChange={setPrepaidMonthCount}
964
+ />
965
+ )}
966
+
967
+ {selectedGroupIds.length > 0 && !isSelectedRangePaid && !isBlocked && (
909
968
  <>
910
969
  <ProductSelection
911
970
  products={products}
@@ -965,7 +1024,7 @@ export function SubscriptionInvoice({
965
1024
  <SubscriptionAttendanceSummary
966
1025
  selectedGroupIds={selectedGroupIds}
967
1026
  selectedMonth={effectiveSelectedMonth}
968
- isSelectedMonthPaid={isSelectedMonthPaid}
1027
+ isSelectedMonthPaid={isSelectedRangePaid}
969
1028
  locale={locale}
970
1029
  navigateMonth={navigateMonth}
971
1030
  canNavigateMonth={canNavigateMonth}
@@ -979,11 +1038,13 @@ export function SubscriptionInvoice({
979
1038
  attendanceStats={attendanceStats}
980
1039
  totalSessions={totalSessions}
981
1040
  attendanceRate={attendanceRate}
1041
+ coverageRangeLabel={coverageRangeLabel}
1042
+ prepaidMonthCount={prepaidMonthCount}
982
1043
  />
983
1044
  )}
984
1045
 
985
1046
  {subscriptionSelectedProducts.length > 0 &&
986
- !isSelectedMonthPaid && (
1047
+ !isSelectedRangePaid && (
987
1048
  <Card>
988
1049
  <CardHeader>
989
1050
  <CardTitle>
@@ -1095,7 +1156,7 @@ export function SubscriptionInvoice({
1095
1156
  !selectedWalletId ||
1096
1157
  !selectedCategoryId ||
1097
1158
  isCreating ||
1098
- isSelectedMonthPaid
1159
+ isSelectedRangePaid
1099
1160
  }
1100
1161
  >
1101
1162
  {isCreating ? (
@@ -1,11 +1,16 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
+ formatCoverageRangeLabel,
4
+ getAvailableMonths,
3
5
  getBillableAttendanceRecords,
6
+ getBillableQuantityMapForGroupsRange,
4
7
  getBillableSessionsForGroups,
8
+ getCoverageMonths,
5
9
  getLinkedFinanceCategorySelection,
6
10
  getSubscriptionAttendanceDisplayData,
7
11
  getSubscriptionCoverageInvoiceForGroup,
8
12
  isSubscriptionMonthPaidForGroup,
13
+ isSubscriptionRangeFullyPaidForGroups,
9
14
  type UserGroup,
10
15
  } from './utils';
11
16
 
@@ -138,6 +143,21 @@ describe('subscription invoice attendance display data', () => {
138
143
  });
139
144
 
140
145
  describe('subscription invoice coverage', () => {
146
+ it('builds coverage months and labels multi-month ranges', () => {
147
+ expect(getCoverageMonths('2026-06', 3)).toEqual([
148
+ '2026-06',
149
+ '2026-07',
150
+ '2026-08',
151
+ ]);
152
+ expect(
153
+ formatCoverageRangeLabel({
154
+ locale: 'en-US',
155
+ prepaidMonthCount: 3,
156
+ selectedMonth: '2026-06',
157
+ })
158
+ ).toBe('June 2026 - August 2026');
159
+ });
160
+
141
161
  it('treats valid_until as the exclusive first unpaid month', () => {
142
162
  const latestInvoices = [{ group_id: groupId, valid_until: '2026-06-01' }];
143
163
 
@@ -183,6 +203,68 @@ describe('subscription invoice coverage', () => {
183
203
  isSubscriptionMonthPaidForGroup(groupId, '2026-06', latestInvoices)
184
204
  ).toBe(true);
185
205
  });
206
+
207
+ it('requires every coverage month to be paid before marking a range paid', () => {
208
+ expect(
209
+ isSubscriptionRangeFullyPaidForGroups([groupId], '2026-06', 3, [
210
+ { group_id: groupId, valid_until: '2026-08-01' },
211
+ ])
212
+ ).toBe(false);
213
+ expect(
214
+ isSubscriptionRangeFullyPaidForGroups([groupId], '2026-06', 3, [
215
+ { group_id: groupId, valid_until: '2026-09-01' },
216
+ ])
217
+ ).toBe(true);
218
+ });
219
+
220
+ it('generates prepaid future month options capped by group ending dates', () => {
221
+ const groups = [
222
+ {
223
+ workspace_user_groups: {
224
+ id: groupId,
225
+ name: 'Math 7',
226
+ sessions: ['2026-06-05', '2026-07-05', '2026-08-05'],
227
+ starting_date: '2026-06-01',
228
+ ending_date: '2026-08-31',
229
+ } as NonNullable<UserGroup['workspace_user_groups']>,
230
+ },
231
+ ] satisfies UserGroup[];
232
+
233
+ expect(
234
+ getAvailableMonths(groups, [groupId], [], 'en-US', '2026-06', 12).map(
235
+ (month) => month.value
236
+ )
237
+ ).toEqual(['2026-06', '2026-07', '2026-08']);
238
+ });
239
+
240
+ it('skips paid months and uses scheduled sessions for future prepaid quantities', () => {
241
+ const groups = [
242
+ {
243
+ workspace_user_groups: {
244
+ id: groupId,
245
+ name: 'Math 7',
246
+ sessions: ['2026-06-05', '2026-07-05', '2026-08-05'],
247
+ starting_date: '2026-06-01',
248
+ ending_date: null,
249
+ } as NonNullable<UserGroup['workspace_user_groups']>,
250
+ },
251
+ ] satisfies UserGroup[];
252
+
253
+ expect(
254
+ getBillableQuantityMapForGroupsRange({
255
+ groupIds: [groupId],
256
+ latestInvoices: [{ group_id: groupId, valid_until: '2026-07-01' }],
257
+ now: new Date(2026, 5, 15),
258
+ prepaidMonthCount: 3,
259
+ selectedMonth: '2026-06',
260
+ useAttendanceBased: true,
261
+ userAttendance: [
262
+ { group_id: groupId, date: '2026-06-05', status: 'PRESENT' },
263
+ ],
264
+ userGroups: groups,
265
+ })
266
+ ).toEqual({ [groupId]: 2 });
267
+ });
186
268
  });
187
269
 
188
270
  describe('linked finance category selection', () => {