@tuturuuu/ui 0.37.2 → 0.39.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 (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -1,14 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { useQueryClient } from '@tanstack/react-query';
4
- import { AlertTriangle, Calculator, Loader2, Plus } from '@tuturuuu/icons';
4
+ import { AlertTriangle, Calculator, Plus } from '@tuturuuu/icons';
5
5
  import { Button } from '@tuturuuu/ui/button';
6
6
  import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
7
7
  import { Separator } from '@tuturuuu/ui/separator';
8
- import { toast } from '@tuturuuu/ui/sonner';
9
8
  import { resolveSupportedCurrency } from '@tuturuuu/utils/currencies';
10
9
  import { shouldLockFinanceWalletSelectionOnCreate } from '@tuturuuu/utils/finance';
11
- import { useRouter } from 'next/navigation';
12
10
  import { useLocale, useTranslations } from 'next-intl';
13
11
  import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
14
12
  import {
@@ -20,7 +18,6 @@ import {
20
18
  useState,
21
19
  } from 'react';
22
20
  import { useDebounce } from '../../../../hooks/use-debounce';
23
- import { useFinanceHref } from '../finance-route-context';
24
21
  import { FinancePermissionWarningDialog } from '../shared/finance-permission-warning-dialog';
25
22
  import { useFinanceConfidentialVisibility } from '../shared/use-finance-confidential-visibility';
26
23
  import { InvoiceBlockedState } from './components/invoice-blocked-state';
@@ -34,6 +31,7 @@ import {
34
31
  isPermissionRequestError,
35
32
  } from './components/invoice-products-permission-warning';
36
33
  import { SubscriptionAttendanceSummary } from './components/subscription-attendance-summary';
34
+ import { SubscriptionCreateAction } from './components/subscription-create-action';
37
35
  import { SubscriptionGroupSelector } from './components/subscription-group-selector';
38
36
  import { SubscriptionInvoiceProposal } from './components/subscription-invoice-proposal';
39
37
  import { SubscriptionPrepaidControls } from './components/subscription-prepaid-controls';
@@ -57,12 +55,17 @@ import { useBestPromotionSelection } from './hooks/use-best-promotion-selection'
57
55
  import { useInvoiceRounding } from './hooks/use-invoice-rounding';
58
56
  import { useInvoiceSubtotal } from './hooks/use-invoice-subtotal';
59
57
  import { useSubscriptionAutoSelection } from './hooks/use-subscription-auto-selection';
58
+ import { useSubscriptionCreate } from './hooks/use-subscription-create';
60
59
  import { useSubscriptionInvoiceContent } from './hooks/use-subscription-invoice-content';
61
- import { createSubscriptionInvoiceWithInternalApi } from './internal-api';
62
- import { formatInvoiceRecalculationDescription } from './invoice-visibility-format';
63
60
  import { ProductSelection } from './product-selection';
64
- import { invalidateInvoiceMutationQueries } from './query-invalidation';
65
- import { useSubscriptionBillingGroups } from './subscription-billing-groups';
61
+ import {
62
+ getSubscriptionBlockReason,
63
+ isSelectedSubscriptionRangePaid,
64
+ } from './subscription-admission';
65
+ import {
66
+ getBillingScheduleWindow,
67
+ useSubscriptionBillingGroups,
68
+ } from './subscription-billing-groups';
66
69
  import type { SubscriptionInvoiceProps } from './subscription-invoice-props';
67
70
  import type { SelectedProductItem } from './types';
68
71
  import {
@@ -78,7 +81,6 @@ import {
78
81
  getGroupsDateRange,
79
82
  getMonthStartDate,
80
83
  getSubscriptionAttendanceDisplayData,
81
- isSubscriptionRangeFullyPaidForGroups,
82
84
  PREPAID_MONTH_OPTION_HORIZON,
83
85
  resolveSubscriptionInvoiceCategoryId,
84
86
  } from './utils';
@@ -103,12 +105,10 @@ export function SubscriptionInvoice({
103
105
  }: SubscriptionInvoiceProps) {
104
106
  const t = useTranslations();
105
107
  const locale = useLocale();
108
+ const queryClient = useQueryClient();
106
109
  const defaultCurrency = resolveSupportedCurrency(rawDefaultCurrency);
107
110
  const { isConfidential: areNumbersHidden } =
108
111
  useFinanceConfidentialVisibility();
109
- const router = useRouter();
110
- const queryClient = useQueryClient();
111
- const financeHref = useFinanceHref();
112
112
 
113
113
  const [selectedUserId, setSelectedUserId] = useQueryState('user_id', {
114
114
  defaultValue: '',
@@ -242,7 +242,6 @@ export function SubscriptionInvoice({
242
242
 
243
243
  const [invoiceContent, setInvoiceContent] = useState<string>('');
244
244
  const [invoiceNotes, setInvoiceNotes] = useState<string>('');
245
- const [isCreating, setIsCreating] = useState(false);
246
245
  const [createPromotionOpen, setCreatePromotionOpen] = useState(false);
247
246
 
248
247
  const prevUserIdRef = useRef<string | null>(null);
@@ -334,10 +333,15 @@ export function SubscriptionInvoice({
334
333
  const userAttendance = subscriptionInvoiceContext?.attendance ?? [];
335
334
  const latestSubscriptionInvoices =
336
335
  subscriptionInvoiceContext?.latestInvoices ?? [];
336
+ const scheduleWindow = useMemo(
337
+ () => getBillingScheduleWindow(effectiveSelectedMonth, prepaidMonthCount),
338
+ [effectiveSelectedMonth, prepaidMonthCount]
339
+ );
337
340
  const { billingUserGroups, groupsWithScheduleIds } =
338
341
  useSubscriptionBillingGroups(
339
342
  userGroups,
340
- subscriptionInvoiceContext?.scheduledSessionsByGroupId
343
+ subscriptionInvoiceContext?.scheduledSessionsByGroupId,
344
+ scheduleWindow
341
345
  );
342
346
  const userAttendanceError =
343
347
  subscriptionInvoiceContextError instanceof Error
@@ -365,27 +369,12 @@ export function SubscriptionInvoice({
365
369
  [effectiveSelectedMonth, locale, prepaidMonthCount]
366
370
  );
367
371
 
368
- const isSelectedRangePaid = useMemo(() => {
369
- if (selectedGroupIds.length === 0 || !effectiveSelectedMonth) return false;
370
-
371
- const selectedMonthStart = getMonthStartDate(effectiveSelectedMonth);
372
-
373
- // A range is paid only if all selected groups have coverage for every month.
374
- // If any selected group has an unpaid month, invoice creation stays enabled.
375
- if (Number.isNaN(selectedMonthStart.getTime())) return false;
376
-
377
- return isSubscriptionRangeFullyPaidForGroups(
378
- selectedGroupIds,
379
- effectiveSelectedMonth,
380
- prepaidMonthCount,
381
- latestSubscriptionInvoices
382
- );
383
- }, [
372
+ const isSelectedRangePaid = isSelectedSubscriptionRangePaid(
373
+ selectedGroupIds,
384
374
  effectiveSelectedMonth,
385
- latestSubscriptionInvoices,
386
375
  prepaidMonthCount,
387
- selectedGroupIds,
388
- ]);
376
+ latestSubscriptionInvoices
377
+ );
389
378
 
390
379
  const billableAttendance = useMemo(
391
380
  () =>
@@ -539,7 +528,7 @@ export function SubscriptionInvoice({
539
528
  return map;
540
529
  }, [referralDiscountRows]);
541
530
 
542
- useSubscriptionAutoSelection({
531
+ const autoSelection = useSubscriptionAutoSelection({
543
532
  enabled: true,
544
533
  selectedGroupIds,
545
534
  selectedMonth: effectiveSelectedMonth,
@@ -770,37 +759,30 @@ export function SubscriptionInvoice({
770
759
  : currentIndex < availableMonths.length - 1;
771
760
  };
772
761
 
773
- const handleCreateSubscriptionInvoice = async () => {
774
- if (
775
- !selectedUser ||
776
- selectedGroupIdsForCreate.length === 0 ||
777
- subscriptionSelectedProducts.length === 0 ||
778
- !selectedWalletId ||
779
- !selectedCategoryId
780
- ) {
781
- toast(t('ws-invoices.create_subscription_invoice_validation'));
782
- return;
783
- }
784
-
785
- const productsPayload = subscriptionSelectedProducts
786
- .filter((item) => item.quantity > 0)
787
- .map((item) => ({
788
- product_id: item.product.id,
789
- unit_id: item.inventory.unit_id,
790
- warehouse_id: item.inventory.warehouse_id,
791
- quantity: item.quantity,
792
- price: item.inventory.price,
793
- category_id: item.product.category_id,
794
- }));
795
-
796
- if (productsPayload.length === 0) {
797
- toast(t('ws-invoices.no_products_to_invoice'));
798
- return;
799
- }
800
-
801
- setIsCreating(true);
802
- try {
803
- const result = await createSubscriptionInvoiceWithInternalApi(wsId, {
762
+ const blockedReason = getSubscriptionBlockReason(
763
+ autoSelection?.blocked ?? false,
764
+ !!scheduleWindow,
765
+ isLoadingSubscriptionData || productsLoading,
766
+ [
767
+ subscriptionInvoiceContextError,
768
+ groupProductsError,
769
+ productsError,
770
+ userGroupsError,
771
+ ],
772
+ selectedGroupIds,
773
+ subscriptionInvoiceContext?.scheduledSessionsByGroupId
774
+ );
775
+ const checkoutReady = !!(
776
+ selectedUser &&
777
+ selectedGroupIdsForCreate.length &&
778
+ subscriptionSelectedProducts.length &&
779
+ selectedWalletId &&
780
+ selectedCategoryId
781
+ );
782
+ const { submit: handleCreateSubscriptionInvoice, creating: isCreating } =
783
+ useSubscriptionCreate({
784
+ wsId,
785
+ payload: {
804
786
  customer_id: selectedUserId,
805
787
  group_ids: selectedGroupIdsForCreate,
806
788
  selected_month: effectiveSelectedMonth,
@@ -809,45 +791,32 @@ export function SubscriptionInvoice({
809
791
  wallet_id: selectedWalletId,
810
792
  promotion_id:
811
793
  selectedPromotionId !== 'none' ? selectedPromotionId : undefined,
812
- products: productsPayload,
794
+ products: subscriptionSelectedProducts
795
+ .filter((item) => item.quantity > 0)
796
+ .map((item) => ({
797
+ product_id: item.product.id,
798
+ unit_id: item.inventory.unit_id,
799
+ warehouse_id: item.inventory.warehouse_id,
800
+ quantity: item.quantity,
801
+ price: item.inventory.price,
802
+ category_id: item.product.category_id,
803
+ })),
813
804
  category_id: selectedCategoryId,
814
805
  frontend_subtotal: subtotal,
815
806
  frontend_discount_amount: discountAmount,
816
807
  frontend_total: subscriptionRoundedTotal,
817
808
  prepaid_month_count: prepaidMonthCount,
818
- });
819
- void invalidateInvoiceMutationQueries(queryClient, wsId);
820
-
821
- if (result.data?.values_recalculated) {
822
- const { calculated_values, frontend_values } = result.data;
823
-
824
- toast(t('ws-invoices.subscription_invoice_created_recalculated'), {
825
- description: formatInvoiceRecalculationDescription({
826
- areNumbersHidden,
827
- calculatedTotal: calculated_values.total,
828
- currency: defaultCurrency,
829
- frontendTotal: frontend_values?.total || 0,
830
- roundingApplied: calculated_values.rounding_applied,
831
- t,
832
- }),
833
- duration: 5000,
834
- });
835
- } else {
836
- toast(
837
- t('ws-invoices.subscription_invoice_created_success', {
838
- invoiceId: result.invoice_id,
839
- })
840
- );
841
- }
842
-
843
- if (!createMultipleInvoices) {
844
- const queryParams = new URLSearchParams();
845
- if (printAfterCreate) queryParams.set('print', 'true');
846
- if (downloadImageAfterCreate) queryParams.set('image', 'true');
847
- router.push(
848
- `/${wsId}${financeHref(`/invoices/${result.invoice_id}`)}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
849
- );
850
- } else {
809
+ },
810
+ options: {
811
+ createMultipleInvoices,
812
+ printAfterCreate,
813
+ downloadImageAfterCreate,
814
+ },
815
+ blockedReason,
816
+ checkoutReady,
817
+ areNumbersHidden,
818
+ currency: defaultCurrency,
819
+ onReset: () => {
851
820
  setSubscriptionSelectedProducts([]);
852
821
  setSelectedPromotionId('none');
853
822
  setInvoiceContent('');
@@ -859,20 +828,8 @@ export function SubscriptionInvoice({
859
828
  setSelectedGroupIds(null);
860
829
  setPrepaidMonthCount(1);
861
830
  setCustomerSearch('');
862
- }
863
- } catch (error) {
864
- toast(
865
- t('ws-invoices.error_creating_subscription_invoice', {
866
- error:
867
- error instanceof Error
868
- ? error.message
869
- : t('ws-invoices.failed_to_create_subscription_invoice'),
870
- })
871
- );
872
- } finally {
873
- setIsCreating(false);
874
- }
875
- };
831
+ },
832
+ });
876
833
 
877
834
  if (
878
835
  isLoadingData ||
@@ -1158,28 +1115,12 @@ export function SubscriptionInvoice({
1158
1115
  currency={defaultCurrency}
1159
1116
  />
1160
1117
 
1161
- <Button
1162
- className="w-full"
1163
- onClick={handleCreateSubscriptionInvoice}
1164
- disabled={
1165
- !selectedUser ||
1166
- selectedGroupIds.length === 0 ||
1167
- subscriptionSelectedProducts.length === 0 ||
1168
- !selectedWalletId ||
1169
- !selectedCategoryId ||
1170
- isCreating ||
1171
- isSelectedRangePaid
1172
- }
1173
- >
1174
- {isCreating ? (
1175
- <>
1176
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1177
- {t('ws-invoices.creating_subscription_invoice')}
1178
- </>
1179
- ) : (
1180
- t('ws-invoices.create_subscription_invoice')
1181
- )}
1182
- </Button>
1118
+ <SubscriptionCreateAction
1119
+ onCreate={handleCreateSubscriptionInvoice}
1120
+ blockedReason={blockedReason}
1121
+ creating={isCreating}
1122
+ disabled={!checkoutReady || isSelectedRangePaid}
1123
+ />
1183
1124
  </div>
1184
1125
  </CardContent>
1185
1126
  </Card>
@@ -83,11 +83,11 @@ export interface UserGroupProducts {
83
83
  name: string | null;
84
84
  product_categories: {
85
85
  name: string | null;
86
- };
87
- };
86
+ } | null;
87
+ } | null;
88
88
  inventory_units: {
89
89
  name: string | null;
90
90
  id: string;
91
- };
91
+ } | null;
92
92
  warehouse_id: string | null;
93
93
  }
@@ -0,0 +1,334 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import {
5
+ useWorkspaceActor,
6
+ WorkspaceVisibilityProvider,
7
+ } from '../../../../hooks/use-workspace-visibility';
8
+ import { useSubscriptionInvoiceContext, useUserGroups } from './hooks';
9
+ import { invalidateInvoiceMutationQueries } from './query-invalidation';
10
+ import type { UserGroup } from './utils';
11
+
12
+ function context(owner: string): UserGroup[] {
13
+ return [
14
+ {
15
+ workspace_user_groups: {
16
+ archived: false,
17
+ cert_template: 'original',
18
+ created_at: null,
19
+ creator_id: null,
20
+ description: null,
21
+ is_course_published: false,
22
+ is_guest: false,
23
+ notes: null,
24
+ ws_id: 'ws-1',
25
+ id: 'group-1',
26
+ name: owner,
27
+ starting_date: null,
28
+ ending_date: null,
29
+ sessions: ['2026-09-01'],
30
+ },
31
+ },
32
+ ];
33
+ }
34
+ function reply(owner: string) {
35
+ return new Response(JSON.stringify(context(owner)), {
36
+ status: 200,
37
+ headers: { 'content-type': 'application/json' },
38
+ });
39
+ }
40
+ function held() {
41
+ let resolve!: (response: Response) => void;
42
+ let reject!: (error: Error) => void;
43
+ const promise = new Promise<Response>((yes, no) => {
44
+ resolve = yes;
45
+ reject = no;
46
+ });
47
+ return { promise, resolve, reject };
48
+ }
49
+ const transport = vi.fn();
50
+ let observedQuery: ReturnType<typeof useUserGroups>;
51
+ let actorLease: ReturnType<typeof useWorkspaceActor>;
52
+ function Probe({ wsId, customerId }: { wsId: string; customerId: string }) {
53
+ actorLease = useWorkspaceActor();
54
+ const query = useUserGroups(wsId, customerId);
55
+ observedQuery = query;
56
+ return (
57
+ <output aria-label="Synthetic context owner">
58
+ {query.data?.[0]?.workspace_user_groups?.name ?? 'pending'}
59
+ </output>
60
+ );
61
+ }
62
+ function mount() {
63
+ // Matches satellite ClientProviders: the QueryClient survives actor remounts.
64
+ const client = new QueryClient({
65
+ defaultOptions: { queries: { retry: false } },
66
+ });
67
+ const view = (actor: string, wsId: string, customerId: string) => (
68
+ <QueryClientProvider client={client}>
69
+ <WorkspaceVisibilityProvider actorId={actor}>
70
+ <Probe wsId={wsId} customerId={customerId} />
71
+ </WorkspaceVisibilityProvider>
72
+ </QueryClientProvider>
73
+ );
74
+ const result = render(view('account-A', 'ws-1', 'customer-1'));
75
+ return {
76
+ ...result,
77
+ client,
78
+ redraw: (actor = 'account-A', ws = 'ws-1', customer = 'customer-1') =>
79
+ result.rerender(view(actor, ws, customer)),
80
+ };
81
+ }
82
+ async function displayed(owner: string) {
83
+ await waitFor(() =>
84
+ expect(screen.getByLabelText('Synthetic context owner')).toHaveTextContent(
85
+ owner
86
+ )
87
+ );
88
+ }
89
+ afterEach(() => {
90
+ cleanup();
91
+ vi.unstubAllGlobals();
92
+ });
93
+ beforeEach(() => {
94
+ transport.mockReset();
95
+ transport.mockImplementation(() => Promise.resolve(reply('account-A')));
96
+ vi.stubGlobal('fetch', transport);
97
+ });
98
+
99
+ describe('actual user groups transport and shared cache actor lifetime', () => {
100
+ it('reauthorizes a fresh account-A context after changing to account-B at identical coordinates', async () => {
101
+ const view = mount();
102
+ await displayed('account-A');
103
+ const expired = actorLease;
104
+ transport.mockImplementation(() => Promise.resolve(reply('account-B')));
105
+ view.redraw('account-B');
106
+ expect(() => expired?.assertActive()).toThrow('Workspace account changed');
107
+ await waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
108
+ await displayed('account-B');
109
+ expect(transport.mock.calls[0]?.[0]).toContain(
110
+ '/api/v1/workspaces/ws-1/users/customer-1/user-groups'
111
+ );
112
+ expect(transport.mock.calls[1]?.[1]).toEqual(
113
+ expect.objectContaining({
114
+ cache: 'no-store',
115
+ signal: expect.any(AbortSignal),
116
+ })
117
+ );
118
+ });
119
+ it('does not deliver an old account-A held transport into the account-B observer or cache', async () => {
120
+ const old = held();
121
+ transport
122
+ .mockReturnValueOnce(old.promise)
123
+ .mockImplementation(() => Promise.resolve(reply('account-B')));
124
+ const view = mount();
125
+ await waitFor(() => expect(transport).toHaveBeenCalledOnce());
126
+ const expired = actorLease;
127
+ view.redraw('account-B');
128
+ expect(() => expired?.assertActive()).toThrow('Workspace account changed');
129
+ await act(async () => {
130
+ old.resolve(reply('account-A'));
131
+ await old.promise;
132
+ });
133
+ await waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
134
+ await displayed('account-B');
135
+ expect(
136
+ view.client
137
+ .getQueryCache()
138
+ .getAll()
139
+ .filter(
140
+ (entry) =>
141
+ entry.queryKey[0] === 'user-groups' && entry.getObserversCount() > 0
142
+ )
143
+ .map((entry) => entry.state.data)
144
+ ).toEqual([context('account-B')]);
145
+ });
146
+ it('reuses fresh context inside the same actor lifetime', async () => {
147
+ const view = mount();
148
+ await displayed('account-A');
149
+ view.redraw();
150
+ await displayed('account-A');
151
+ expect(transport).toHaveBeenCalledOnce();
152
+ });
153
+ for (const boundary of ['workspace', 'customer'] as const) {
154
+ it(`keeps same-actor ${boundary} contexts distinct`, async () => {
155
+ const view = mount();
156
+ await displayed('account-A');
157
+ transport.mockImplementation(() =>
158
+ Promise.resolve(reply('other-context'))
159
+ );
160
+ view.redraw(
161
+ 'account-A',
162
+ boundary === 'workspace' ? 'ws-2' : 'ws-1',
163
+ boundary === 'customer' ? 'customer-2' : 'customer-1'
164
+ );
165
+ await displayed('other-context');
166
+ expect(transport).toHaveBeenCalledTimes(2);
167
+ const url = new URL(transport.mock.calls[1]?.[0], window.location.origin);
168
+ expect(url.pathname).toContain(
169
+ boundary === 'workspace' ? '/workspaces/ws-2/' : '/workspaces/ws-1/'
170
+ );
171
+ expect(url.pathname).toContain(
172
+ `/users/${boundary === 'customer' ? 'customer-2' : 'customer-1'}/user-groups`
173
+ );
174
+ });
175
+ }
176
+ for (const outcome of ['success', 'error'] as const) {
177
+ it(`rejects held old actor ${outcome} after account ABA`, async () => {
178
+ const old = held();
179
+ transport
180
+ .mockReturnValueOnce(old.promise)
181
+ .mockImplementation(() => Promise.resolve(reply('new-lifetime')));
182
+ const view = mount();
183
+ await waitFor(() => expect(transport).toHaveBeenCalledOnce());
184
+ const expired = actorLease;
185
+ view.redraw('account-B');
186
+ await displayed('new-lifetime');
187
+ view.redraw('account-A');
188
+ await displayed('new-lifetime');
189
+ expect(transport).toHaveBeenCalledTimes(3);
190
+ expect(() => expired?.assertActive()).toThrow(
191
+ 'Workspace account changed'
192
+ );
193
+ await act(async () => {
194
+ if (outcome === 'success') old.resolve(reply('expired-lifetime'));
195
+ else old.reject(new Error('Synthetic expired transport failure'));
196
+ await old.promise.catch(() => {});
197
+ });
198
+ await displayed('new-lifetime');
199
+ await waitFor(() =>
200
+ expect(
201
+ view.client
202
+ .getQueryCache()
203
+ .getAll()
204
+ .find((entry) => entry.state.error)?.state.error
205
+ ).toEqual(new Error('Workspace account changed'))
206
+ );
207
+ expect(
208
+ view.client
209
+ .getQueryCache()
210
+ .getAll()
211
+ .filter((entry) => entry.getObserversCount() > 0)
212
+ .map((entry) => entry.state.data)
213
+ ).toEqual([context('new-lifetime')]);
214
+ });
215
+ }
216
+ it('requires a verified actor for automatic and manual reads', async () => {
217
+ const client = new QueryClient();
218
+ render(
219
+ <QueryClientProvider client={client}>
220
+ <Probe wsId="ws-1" customerId="customer-1" />
221
+ </QueryClientProvider>
222
+ );
223
+ await act(async () => {
224
+ await Promise.resolve();
225
+ });
226
+ expect(transport).not.toHaveBeenCalled();
227
+ let manualResult!: Awaited<ReturnType<typeof observedQuery.refetch>>;
228
+ await act(async () => {
229
+ manualResult = await observedQuery.refetch();
230
+ });
231
+ expect(manualResult.error).toEqual(
232
+ new Error('Workspace account unavailable')
233
+ );
234
+ expect(transport).not.toHaveBeenCalled();
235
+ });
236
+ it('preserves current actor transport error and explicit retry', async () => {
237
+ transport.mockRejectedValueOnce(
238
+ new Error('Synthetic current transport failure')
239
+ );
240
+ mount();
241
+ await waitFor(() =>
242
+ expect(observedQuery.error).toEqual(
243
+ new Error('Synthetic current transport failure')
244
+ )
245
+ );
246
+ expect(transport).toHaveBeenCalledOnce();
247
+ transport.mockImplementation(() => Promise.resolve(reply('recovered')));
248
+ await act(async () => {
249
+ await observedQuery.refetch();
250
+ });
251
+ await displayed('recovered');
252
+ expect(transport).toHaveBeenCalledTimes(2);
253
+ });
254
+ it('retains workspace-prefix refetch and existing invoice invalidation exclusions', async () => {
255
+ const view = mount();
256
+ await displayed('account-A');
257
+ transport.mockImplementation(() => Promise.resolve(reply('refreshed')));
258
+ await act(async () => {
259
+ await invalidateInvoiceMutationQueries(view.client, 'ws-1');
260
+ });
261
+ expect(transport).toHaveBeenCalledOnce();
262
+ await act(async () => {
263
+ await view.client.invalidateQueries({
264
+ queryKey: ['user-groups', 'ws-1'],
265
+ });
266
+ });
267
+ await displayed('refreshed');
268
+ expect(transport).toHaveBeenCalledTimes(2);
269
+ });
270
+ it('shares the same permanent actor token with the actual context hook', async () => {
271
+ function Both() {
272
+ useUserGroups('ws-1', 'customer-1');
273
+ useSubscriptionInvoiceContext(
274
+ 'ws-1',
275
+ 'customer-1',
276
+ ['group-1'],
277
+ '2026-09'
278
+ );
279
+ return null;
280
+ }
281
+ transport.mockImplementation((url: string) =>
282
+ Promise.resolve(
283
+ url.includes('/finance/invoices/')
284
+ ? new Response(
285
+ JSON.stringify({
286
+ attendance: [],
287
+ latestInvoices: [],
288
+ scheduledSessionsByGroupId: {},
289
+ }),
290
+ { status: 200 }
291
+ )
292
+ : reply('shared')
293
+ )
294
+ );
295
+ const client = new QueryClient();
296
+ const content = (actor: string) => (
297
+ <QueryClientProvider client={client}>
298
+ <WorkspaceVisibilityProvider actorId={actor}>
299
+ <Both />
300
+ </WorkspaceVisibilityProvider>
301
+ </QueryClientProvider>
302
+ );
303
+ const view = render(content('account-A'));
304
+ await waitFor(() =>
305
+ expect(
306
+ client
307
+ .getQueryCache()
308
+ .getAll()
309
+ .filter((entry) => entry.state.status === 'success')
310
+ ).toHaveLength(2)
311
+ );
312
+ const keys = client
313
+ .getQueryCache()
314
+ .getAll()
315
+ .map((entry) => entry.queryKey);
316
+ const original = keys.find((key) => key[0] === 'user-groups')?.[4];
317
+ expect(original).toEqual(
318
+ keys.find((key) => key[0] === 'subscription-invoice-context')?.[7]
319
+ );
320
+ view.rerender(content('account-B'));
321
+ await waitFor(() => expect(transport).toHaveBeenCalledTimes(4));
322
+ const active = client
323
+ .getQueryCache()
324
+ .getAll()
325
+ .filter((entry) => entry.getObserversCount() > 0)
326
+ .map((entry) => entry.queryKey);
327
+ expect(active.find((key) => key[0] === 'user-groups')?.[4]).toEqual(
328
+ active.find((key) => key[0] === 'subscription-invoice-context')?.[7]
329
+ );
330
+ expect(active.find((key) => key[0] === 'user-groups')?.[4]).not.toEqual(
331
+ original
332
+ );
333
+ });
334
+ });