@tuturuuu/ui 0.38.0 → 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.
- package/CHANGELOG.md +27 -0
- package/package.json +6 -6
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/text-editor/editor.tsx +3 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/tool-bar.tsx +18 -74
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +33 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/supabase-provider.ts +39 -41
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +24 -38
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-yjs-collaboration.ts +23 -0
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQueryClient } from '@tanstack/react-query';
|
|
4
|
-
import { AlertTriangle, Calculator,
|
|
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 {
|
|
65
|
-
|
|
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 =
|
|
369
|
-
|
|
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
|
-
|
|
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
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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:
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
}
|
|
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
|
-
<
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
+
});
|