@tuturuuu/ui 0.32.0 → 0.34.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 (104) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  23. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  24. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  25. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  26. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  27. package/src/components/ui/custom/report-preview.tsx +2 -3
  28. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  29. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  30. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  31. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  32. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  33. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  34. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  35. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  36. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  37. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  38. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  39. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  40. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  41. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  42. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  43. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  44. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  45. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  46. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  47. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  48. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  49. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  50. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  51. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  52. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  53. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  54. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  55. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  56. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  57. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  58. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  59. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  60. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  61. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  62. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  63. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  64. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  65. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  66. package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
  67. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  68. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  69. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  70. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  71. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  72. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  73. package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
  74. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  75. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  76. package/src/components/ui/finance/invoices/utils.ts +48 -106
  77. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  78. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  79. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  80. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  81. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  82. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  83. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  84. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  85. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  86. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  87. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  88. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  89. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
  90. package/src/components/ui/public-workspace-prices.tsx +13 -0
  91. package/src/components/ui/pwa-status.test.tsx +13 -2
  92. package/src/components/ui/pwa-status.tsx +15 -3
  93. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  94. package/src/components/ui/text-editor/editor.tsx +11 -3
  95. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  96. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  97. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  98. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  99. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  100. package/src/hooks/calendar-provider-sync.ts +33 -0
  101. package/src/hooks/notification-subscription.ts +74 -0
  102. package/src/hooks/use-calendar-sync.tsx +11 -14
  103. package/src/hooks/use-calendar.tsx +2 -2
  104. package/src/hooks/use-notifications.ts +5 -141
@@ -23,6 +23,7 @@ import { InvoiceBlockedState } from './components/invoice-blocked-state';
23
23
  import { InvoiceCheckoutSummary } from './components/invoice-checkout-summary';
24
24
  import { InvoiceContentEditor } from './components/invoice-content-editor';
25
25
  import { InvoiceCustomerSelectCard } from './components/invoice-customer-select-card';
26
+ import { InvoiceDataState } from './components/invoice-data-state';
26
27
  import { InvoicePaymentSettings } from './components/invoice-payment-settings';
27
28
  import {
28
29
  InvoiceProductsPermissionWarning,
@@ -106,6 +107,7 @@ export function StandardInvoice({
106
107
  selectedUser,
107
108
  isLoading: usersLoading,
108
109
  error: usersError,
110
+ refetch: refetchUsers,
109
111
  hasNextPage: hasMoreCustomers,
110
112
  fetchNextPage: fetchMoreCustomers,
111
113
  isFetching: isFetchingCustomers,
@@ -114,6 +116,7 @@ export function StandardInvoice({
114
116
  const {
115
117
  data: products = [],
116
118
  error: productsError,
119
+ refetch: refetchProducts,
117
120
  isLoading: productsLoading,
118
121
  } = useProducts(wsId, { enabled: canReadInvoiceProducts });
119
122
  const { data: availablePromotions = [] } = useAvailablePromotions(
@@ -147,10 +150,12 @@ export function StandardInvoice({
147
150
  const { data: blockedGroupIds = [] } = useInvoiceBlockedGroups(wsId, {
148
151
  enabled: !!selectedUserId,
149
152
  });
150
- const { data: userGroups = [], isLoading: userGroupsLoading } = useUserGroups(
151
- wsId,
152
- selectedUserId
153
- );
153
+ const {
154
+ data: userGroups = [],
155
+ isLoading: userGroupsLoading,
156
+ error: userGroupsError,
157
+ refetch: refetchUserGroups,
158
+ } = useUserGroups(wsId, selectedUserId);
154
159
 
155
160
  const isBlocked = useMemo(() => {
156
161
  if (
@@ -443,16 +448,25 @@ export function StandardInvoice({
443
448
  }
444
449
  };
445
450
 
446
- if (isLoadingData) {
451
+ if (
452
+ isLoadingData ||
453
+ usersError ||
454
+ (canReadInvoiceProducts &&
455
+ productsError &&
456
+ !isPermissionRequestError(productsError)) ||
457
+ userGroupsError
458
+ ) {
447
459
  return (
448
- <div className="flex items-center justify-center py-8">
449
- <div className="flex items-center gap-2">
450
- <Loader2 className="h-4 w-4 animate-spin" />
451
- <p className="text-muted-foreground text-sm">
452
- {t('ws-invoices.loading')}
453
- </p>
454
- </div>
455
- </div>
460
+ <InvoiceDataState
461
+ loading={isLoadingData}
462
+ onRetry={() =>
463
+ void Promise.all([
464
+ refetchUsers(),
465
+ ...(canReadInvoiceProducts ? [refetchProducts()] : []),
466
+ ...(selectedUserId ? [refetchUserGroups()] : []),
467
+ ])
468
+ }
469
+ />
456
470
  );
457
471
  }
458
472
 
@@ -475,9 +489,6 @@ export function StandardInvoice({
475
489
  isFetchingNextPage={isFetchingMoreCustomers}
476
490
  hasNextPage={hasMoreCustomers}
477
491
  onLoadMore={() => void fetchMoreCustomers()}
478
- errorMessage={
479
- usersError instanceof Error ? usersError.message : undefined
480
- }
481
492
  emptyMessage={t('ws-invoices.no_customers_found')}
482
493
  searchValue={customerSearch}
483
494
  onSearchChange={setCustomerSearch}
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ isSubscriptionMonthCoveredByInvoice as covered,
4
+ isSubscriptionMonthPaidForGroup as paid,
5
+ isSubscriptionRangePaidForGroup as range,
6
+ type SubscriptionCoverageInvoice,
7
+ } from './subscription-coverage';
8
+ import { getGroupPaymentStatus, type WorkspaceUserGroup } from './utils';
9
+
10
+ const explicit: SubscriptionCoverageInvoice = {
11
+ group_id: 'g1',
12
+ valid_until: '2026-04-01T00:00:00Z',
13
+ covered_months: ['2026-01-01', '2026-03-01'],
14
+ };
15
+ describe('explicit tuition coverage', () => {
16
+ afterEach(() => vi.useRealTimers());
17
+ it.each(['2026-01', '2026-03'])('covers recorded month %s', (month) =>
18
+ expect(covered(month, explicit)).toBe(true)
19
+ );
20
+ it.each(['2025-12', '2026-02', '2026-04'])(
21
+ 'does not infer unrecorded month %s from valid_until',
22
+ (month) => expect(covered(month, explicit)).toBe(false)
23
+ );
24
+ it('uses months even without a compatibility expiry', () =>
25
+ expect(covered('2026-01', { covered_months: ['2026-01-01'] })).toBe(true));
26
+ it('keeps null legacy coverage semantics', () => {
27
+ const legacy = { valid_until: '2026-03-01', covered_months: null };
28
+ expect(covered('2026-02', legacy)).toBe(true);
29
+ expect(covered('2026-03', legacy)).toBe(false);
30
+ });
31
+ it('treats empty explicit coverage as empty rather than legacy', () =>
32
+ expect(covered('2026-01', { ...explicit, covered_months: [] })).toBe(
33
+ false
34
+ ));
35
+ it('isolates groups', () =>
36
+ expect(paid('g2', '2026-01', [explicit])).toBe(false));
37
+ it('combines separate payments without masking gaps', () => {
38
+ const rows = [explicit, { ...explicit, covered_months: ['2026-02-01'] }];
39
+ expect(range('g1', '2026-01', 3, [explicit])).toBe(false);
40
+ expect(range('g1', '2026-01', 3, rows)).toBe(true);
41
+ expect(range('g1', '2026-01', 4, rows)).toBe(false);
42
+ });
43
+ it('combines legacy cutoff and exact modern months', () => {
44
+ const rows = [explicit, { group_id: 'g1', valid_until: '2026-03-01' }];
45
+ expect(range('g1', '2026-01', 3, rows)).toBe(true);
46
+ expect(paid('g1', '2026-04', rows)).toBe(false);
47
+ });
48
+ it('does not label an unpaid current-month gap active', () => {
49
+ vi.useFakeTimers();
50
+ vi.setSystemTime(new Date(2026, 1, 10));
51
+ expect(
52
+ getGroupPaymentStatus({ id: 'g1' } as WorkspaceUserGroup, explicit, [
53
+ explicit,
54
+ ])
55
+ ).toBe('expired');
56
+ });
57
+ it('uses all invoices for the current-month badge', () => {
58
+ vi.useFakeTimers();
59
+ vi.setSystemTime(new Date(2026, 1, 10));
60
+ const rows = [explicit, { ...explicit, covered_months: ['2026-02-01'] }];
61
+ expect(
62
+ getGroupPaymentStatus({ id: 'g1' } as WorkspaceUserGroup, explicit, rows)
63
+ ).toBe('active');
64
+ });
65
+ it('expires at the next gap rather than the latest paid month', () => {
66
+ vi.useFakeTimers();
67
+ vi.setSystemTime(new Date(2026, 0, 25));
68
+ expect(
69
+ getGroupPaymentStatus({ id: 'g1' } as WorkspaceUserGroup, explicit, [
70
+ explicit,
71
+ ])
72
+ ).toBe('expiringSoon');
73
+ });
74
+ });
@@ -0,0 +1,109 @@
1
+ import {
2
+ getCoverageMonths,
3
+ getMonthStartDate,
4
+ parseLocalCalendarDate,
5
+ } from './civil-month';
6
+ export type SubscriptionCoverageInvoice = {
7
+ created_at?: string | null;
8
+ group_id?: string;
9
+ valid_until?: string | null;
10
+ covered_months?: string[] | null;
11
+ };
12
+
13
+ const getComparableTimestamp = (value: string | null | undefined): number => {
14
+ if (!value) return 0;
15
+ const timestamp = new Date(value).getTime();
16
+ return Number.isNaN(timestamp) ? 0 : timestamp;
17
+ };
18
+
19
+ const getComparableValidUntilTimestamp = (
20
+ invoice: SubscriptionCoverageInvoice
21
+ ): number => {
22
+ const validUntil = parseLocalCalendarDate(invoice.valid_until);
23
+ return Number.isNaN(validUntil.getTime()) ? 0 : validUntil.getTime();
24
+ };
25
+
26
+ export const getSubscriptionCoverageInvoiceForGroup = (
27
+ latestInvoices: SubscriptionCoverageInvoice[],
28
+ groupId: string
29
+ ): SubscriptionCoverageInvoice | undefined =>
30
+ latestInvoices
31
+ .filter((invoice) => invoice.group_id === groupId)
32
+ .filter((invoice) => getComparableValidUntilTimestamp(invoice) > 0)
33
+ .sort((a, b) => {
34
+ const validUntilDiff =
35
+ getComparableValidUntilTimestamp(b) -
36
+ getComparableValidUntilTimestamp(a);
37
+ if (validUntilDiff !== 0) return validUntilDiff;
38
+
39
+ return (
40
+ getComparableTimestamp(b.created_at) -
41
+ getComparableTimestamp(a.created_at)
42
+ );
43
+ })[0];
44
+
45
+ export const isSubscriptionMonthCoveredByInvoice = (
46
+ selectedMonth: string,
47
+ invoice: SubscriptionCoverageInvoice | null | undefined
48
+ ): boolean => {
49
+ if (invoice?.covered_months != null)
50
+ return invoice.covered_months.some(
51
+ (month) => month.slice(0, 7) === selectedMonth
52
+ );
53
+ if (!invoice?.valid_until) return false;
54
+
55
+ const selectedMonthStart = getMonthStartDate(selectedMonth);
56
+ const validUntilMonthStart = getMonthStartDate(invoice.valid_until);
57
+
58
+ if (
59
+ Number.isNaN(selectedMonthStart.getTime()) ||
60
+ Number.isNaN(validUntilMonthStart.getTime())
61
+ ) {
62
+ return false;
63
+ }
64
+
65
+ return selectedMonthStart < validUntilMonthStart;
66
+ };
67
+
68
+ export const isSubscriptionMonthPaidForGroup = (
69
+ groupId: string,
70
+ selectedMonth: string,
71
+ latestInvoices: SubscriptionCoverageInvoice[]
72
+ ): boolean =>
73
+ latestInvoices.some(
74
+ (invoice) =>
75
+ invoice.group_id === groupId &&
76
+ isSubscriptionMonthCoveredByInvoice(selectedMonth, invoice)
77
+ );
78
+
79
+ export const isSubscriptionRangePaidForGroup = (
80
+ groupId: string,
81
+ selectedMonth: string,
82
+ prepaidMonthCount: number,
83
+ latestInvoices: SubscriptionCoverageInvoice[]
84
+ ): boolean => {
85
+ const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
86
+ if (coverageMonths.length === 0) return false;
87
+
88
+ return coverageMonths.every((month) =>
89
+ isSubscriptionMonthPaidForGroup(groupId, month, latestInvoices)
90
+ );
91
+ };
92
+
93
+ export const isSubscriptionRangeFullyPaidForGroups = (
94
+ groupIds: string[],
95
+ selectedMonth: string,
96
+ prepaidMonthCount: number,
97
+ latestInvoices: SubscriptionCoverageInvoice[]
98
+ ): boolean => {
99
+ if (groupIds.length === 0) return false;
100
+
101
+ return groupIds.every((groupId) =>
102
+ isSubscriptionRangePaidForGroup(
103
+ groupId,
104
+ selectedMonth,
105
+ prepaidMonthCount,
106
+ latestInvoices
107
+ )
108
+ );
109
+ };
@@ -0,0 +1,20 @@
1
+ import type { FinancePermissionRequestUser } from '../shared/finance-permission-warning-dialog';
2
+
3
+ export interface SubscriptionInvoiceProps {
4
+ wsId: string;
5
+ prefillQuantity?: number | null;
6
+ suggestedTotal?: number | null;
7
+ createMultipleInvoices: boolean;
8
+ printAfterCreate?: boolean;
9
+ downloadImageAfterCreate?: boolean;
10
+ defaultWalletId?: string;
11
+ defaultCategoryId?: string;
12
+ defaultCurrency?: string;
13
+ workspaceTimezone?: string | null;
14
+ canChangeFinanceWallets?: boolean;
15
+ canSetFinanceWalletsOnCreate?: boolean;
16
+ canReadInvoiceProducts?: boolean;
17
+ canReadInvoiceProductStock?: boolean;
18
+ canReadGroupLinkedProducts?: boolean;
19
+ permissionRequestUser?: FinancePermissionRequestUser | null;
20
+ }
@@ -1,5 +1,5 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
- import { render, waitFor } from '@testing-library/react';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import type { ComponentProps, PropsWithChildren } from 'react';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { SubscriptionInvoice } from './subscription-invoice';
@@ -37,6 +37,15 @@ const testState = vi.hoisted(() => {
37
37
  setMonth: vi.fn(),
38
38
  productSelectionInjected: false,
39
39
  products: [product],
40
+ productsError: null as Error | null,
41
+ userGroupsError: null as Error | null,
42
+ groupProductsError: null as Error | null,
43
+ contextError: null as Error | null,
44
+ refetchUserGroups: vi.fn(),
45
+ refetchGroupProducts: vi.fn(),
46
+ refetchContext: vi.fn(),
47
+ refetchProducts: vi.fn(),
48
+ refetchUsers: vi.fn(),
40
49
  selectedProducts: [{ inventory, product, quantity: 1 }],
41
50
  useCategories: vi.fn(),
42
51
  useWallets: vi.fn(),
@@ -170,6 +179,7 @@ vi.mock('./hooks', () => ({
170
179
  useInvoiceBlockedGroups: () => ({ data: [] }),
171
180
  useInvoiceCustomerSearch: () => ({
172
181
  customers: [],
182
+ refetch: testState.refetchUsers,
173
183
  error: null,
174
184
  fetchNextPage: vi.fn(),
175
185
  hasNextPage: false,
@@ -180,21 +190,26 @@ vi.mock('./hooks', () => ({
180
190
  }),
181
191
  useMultiGroupProducts: () => ({
182
192
  data: [],
183
- error: null,
193
+ error: testState.groupProductsError,
194
+ refetch: testState.refetchGroupProducts,
184
195
  isLoading: false,
185
196
  }),
186
197
  useProducts: () => ({
187
198
  data: testState.products,
188
- error: null,
199
+ error: testState.productsError,
200
+ refetch: testState.refetchProducts,
189
201
  isLoading: false,
190
202
  }),
191
203
  useSubscriptionInvoiceContext: () => ({
192
204
  data: { attendance: [], latestInvoices: [] },
193
- error: null,
205
+ error: testState.contextError,
206
+ refetch: testState.refetchContext,
194
207
  isLoading: false,
195
208
  }),
196
209
  useUserGroups: () => ({
197
210
  data: testState.userGroups,
211
+ error: testState.userGroupsError,
212
+ refetch: testState.refetchUserGroups,
198
213
  isLoading: false,
199
214
  }),
200
215
  useUserLinkedPromotions: () => ({ data: [] }),
@@ -272,6 +287,15 @@ describe('SubscriptionInvoice checkout defaults', () => {
272
287
  testState.useCategories.mockClear();
273
288
  testState.useWallets.mockClear();
274
289
  testState.productSelectionInjected = false;
290
+ testState.productsError = null;
291
+ testState.userGroupsError = null;
292
+ testState.groupProductsError = null;
293
+ testState.contextError = null;
294
+ testState.refetchUserGroups.mockClear();
295
+ testState.refetchGroupProducts.mockClear();
296
+ testState.refetchContext.mockClear();
297
+ testState.refetchProducts.mockClear();
298
+ testState.refetchUsers.mockClear();
275
299
  testState.month = '2026-07';
276
300
  testState.setMonth.mockClear();
277
301
  testState.userGroups[0]!.workspace_user_groups.ending_date = null;
@@ -281,6 +305,49 @@ describe('SubscriptionInvoice checkout defaults', () => {
281
305
  testState.categories = [];
282
306
  });
283
307
 
308
+ it('shows a retry when product loading fails and reopens the form after recovery', async () => {
309
+ testState.productsError = new Error('Timed out');
310
+ const { rerender } = renderSubscriptionInvoice();
311
+ expect(screen.getByRole('alert')).toHaveTextContent(
312
+ 'ws-invoices.load_failed'
313
+ );
314
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
315
+ expect(testState.refetchProducts).toHaveBeenCalledOnce();
316
+ expect(testState.refetchUsers).toHaveBeenCalledOnce();
317
+
318
+ testState.productsError = null;
319
+ rerender(
320
+ <SubscriptionInvoice wsId="ws-1" createMultipleInvoices={false} />
321
+ );
322
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
323
+ await waitFor(() =>
324
+ expect(testState.InvoicePaymentSettings).toHaveBeenCalled()
325
+ );
326
+ });
327
+
328
+ it.each(['userGroupsError', 'groupProductsError', 'contextError'] as const)(
329
+ 'recovers from a failed subscription %s read',
330
+ async (key) => {
331
+ testState[key] = new Error('Timed out');
332
+ const { rerender } = renderSubscriptionInvoice();
333
+ expect(screen.getByRole('alert')).toHaveTextContent(
334
+ 'ws-invoices.load_failed'
335
+ );
336
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
337
+ expect(testState.refetchUserGroups).toHaveBeenCalledOnce();
338
+ expect(testState.refetchGroupProducts).toHaveBeenCalledOnce();
339
+ expect(testState.refetchContext).toHaveBeenCalledOnce();
340
+ testState[key] = null;
341
+ rerender(
342
+ <SubscriptionInvoice wsId="ws-1" createMultipleInvoices={false} />
343
+ );
344
+ await waitFor(() =>
345
+ expect(testState.InvoicePaymentSettings).toHaveBeenCalled()
346
+ );
347
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
348
+ }
349
+ );
350
+
284
351
  it('preloads payment options from defaults and keeps default IDs while options load', async () => {
285
352
  const { rerender } = renderSubscriptionInvoice();
286
353
 
@@ -21,15 +21,13 @@ import {
21
21
  } from 'react';
22
22
  import { useDebounce } from '../../../../hooks/use-debounce';
23
23
  import { useFinanceHref } from '../finance-route-context';
24
- import {
25
- type FinancePermissionRequestUser,
26
- FinancePermissionWarningDialog,
27
- } from '../shared/finance-permission-warning-dialog';
24
+ import { FinancePermissionWarningDialog } from '../shared/finance-permission-warning-dialog';
28
25
  import { useFinanceConfidentialVisibility } from '../shared/use-finance-confidential-visibility';
29
26
  import { InvoiceBlockedState } from './components/invoice-blocked-state';
30
27
  import { InvoiceCheckoutSummary } from './components/invoice-checkout-summary';
31
28
  import { InvoiceContentEditor } from './components/invoice-content-editor';
32
29
  import { InvoiceCustomerSelectCard } from './components/invoice-customer-select-card';
30
+ import { InvoiceDataState } from './components/invoice-data-state';
33
31
  import { InvoicePaymentSettings } from './components/invoice-payment-settings';
34
32
  import {
35
33
  InvoiceProductsPermissionWarning,
@@ -63,6 +61,8 @@ import { useSubscriptionInvoiceContent } from './hooks/use-subscription-invoice-
63
61
  import { createSubscriptionInvoiceWithInternalApi } from './internal-api';
64
62
  import { formatInvoiceRecalculationDescription } from './invoice-visibility-format';
65
63
  import { ProductSelection } from './product-selection';
64
+ import { invalidateInvoiceMutationQueries } from './query-invalidation';
65
+ import type { SubscriptionInvoiceProps } from './subscription-invoice-props';
66
66
  import type { SelectedProductItem } from './types';
67
67
  import {
68
68
  formatCoverageRangeLabel,
@@ -82,25 +82,6 @@ import {
82
82
  resolveSubscriptionInvoiceCategoryId,
83
83
  } from './utils';
84
84
 
85
- interface Props {
86
- wsId: string;
87
- prefillQuantity?: number | null;
88
- suggestedTotal?: number | null;
89
- createMultipleInvoices: boolean;
90
- printAfterCreate?: boolean;
91
- downloadImageAfterCreate?: boolean;
92
- defaultWalletId?: string;
93
- defaultCategoryId?: string;
94
- defaultCurrency?: string;
95
- workspaceTimezone?: string | null;
96
- canChangeFinanceWallets?: boolean;
97
- canSetFinanceWalletsOnCreate?: boolean;
98
- canReadInvoiceProducts?: boolean;
99
- canReadInvoiceProductStock?: boolean;
100
- canReadGroupLinkedProducts?: boolean;
101
- permissionRequestUser?: FinancePermissionRequestUser | null;
102
- }
103
-
104
85
  export function SubscriptionInvoice({
105
86
  wsId,
106
87
  prefillQuantity,
@@ -118,7 +99,7 @@ export function SubscriptionInvoice({
118
99
  canReadInvoiceProductStock = true,
119
100
  canReadGroupLinkedProducts = true,
120
101
  permissionRequestUser,
121
- }: Props) {
102
+ }: SubscriptionInvoiceProps) {
122
103
  const t = useTranslations();
123
104
  const locale = useLocale();
124
105
  const defaultCurrency = resolveSupportedCurrency(rawDefaultCurrency);
@@ -128,7 +109,6 @@ export function SubscriptionInvoice({
128
109
  const queryClient = useQueryClient();
129
110
  const financeHref = useFinanceHref();
130
111
 
131
- // URL state using nuqs
132
112
  const [selectedUserId, setSelectedUserId] = useQueryState('user_id', {
133
113
  defaultValue: '',
134
114
  shallow: false,
@@ -170,12 +150,12 @@ export function SubscriptionInvoice({
170
150
  [selectedGroupIds]
171
151
  );
172
152
 
173
- // Data queries
174
153
  const {
175
154
  customers: users,
176
155
  selectedUser,
177
156
  isLoading: usersLoading,
178
157
  error: usersError,
158
+ refetch: refetchUsers,
179
159
  hasNextPage: hasMoreCustomers,
180
160
  fetchNextPage: fetchMoreCustomers,
181
161
  isFetching: isFetchingCustomers,
@@ -184,6 +164,7 @@ export function SubscriptionInvoice({
184
164
  const {
185
165
  data: products = [],
186
166
  error: productsError,
167
+ refetch: refetchProducts,
187
168
  isLoading: productsLoading,
188
169
  } = useProducts(wsId, {
189
170
  enabled: canReadInvoiceProducts && hasSelectedGroups,
@@ -222,7 +203,6 @@ export function SubscriptionInvoice({
222
203
  [selectedGroupIds, blockedGroupIds]
223
204
  );
224
205
 
225
- // State management
226
206
  const [selectedWalletId, setSelectedWalletId] = useState<string>(
227
207
  defaultWalletId || ''
228
208
  );
@@ -268,14 +248,16 @@ export function SubscriptionInvoice({
268
248
  // Track previous user ID to detect user changes (skip initial mount for reset)
269
249
  const prevUserIdRef = useRef<string | null>(null);
270
250
 
271
- // Subscription-specific queries
272
- const { data: userGroups = [], isLoading: userGroupsLoading } = useUserGroups(
273
- wsId,
274
- selectedUserId
275
- );
251
+ const {
252
+ data: userGroups = [],
253
+ isLoading: userGroupsLoading,
254
+ error: userGroupsError,
255
+ refetch: refetchUserGroups,
256
+ } = useUserGroups(wsId, selectedUserId);
276
257
  const {
277
258
  data: groupProducts = [],
278
259
  error: groupProductsError,
260
+ refetch: refetchGroupProducts,
279
261
  isLoading: groupProductsLoading,
280
262
  } = useMultiGroupProducts(wsId, selectedGroupIds, {
281
263
  enabled: canReadGroupLinkedProducts,
@@ -341,6 +323,7 @@ export function SubscriptionInvoice({
341
323
  data: subscriptionInvoiceContext,
342
324
  isLoading: subscriptionInvoiceContextLoading,
343
325
  error: subscriptionInvoiceContextError,
326
+ refetch: refetchSubscriptionContext,
344
327
  } = useSubscriptionInvoiceContext(
345
328
  wsId,
346
329
  selectedUserId,
@@ -547,7 +530,6 @@ export function SubscriptionInvoice({
547
530
  return map;
548
531
  }, [referralDiscountRows]);
549
532
 
550
- // Use hooks for logic
551
533
  useSubscriptionAutoSelection({
552
534
  enabled: true,
553
535
  selectedGroupIds,
@@ -819,7 +801,7 @@ export function SubscriptionInvoice({
819
801
 
820
802
  setIsCreating(true);
821
803
  try {
822
- const requestPayload = {
804
+ const result = await createSubscriptionInvoiceWithInternalApi(wsId, {
823
805
  customer_id: selectedUserId,
824
806
  group_ids: selectedGroupIdsForCreate,
825
807
  selected_month: effectiveSelectedMonth,
@@ -834,12 +816,8 @@ export function SubscriptionInvoice({
834
816
  frontend_discount_amount: discountAmount,
835
817
  frontend_total: subscriptionRoundedTotal,
836
818
  prepaid_month_count: prepaidMonthCount,
837
- };
838
-
839
- const result = await createSubscriptionInvoiceWithInternalApi(wsId, {
840
- ...requestPayload,
841
- customer_id: selectedUserId,
842
819
  });
820
+ void invalidateInvoiceMutationQueries(queryClient, wsId);
843
821
 
844
822
  if (result.data?.values_recalculated) {
845
823
  const { calculated_values, frontend_values } = result.data;
@@ -897,16 +875,37 @@ export function SubscriptionInvoice({
897
875
  }
898
876
  };
899
877
 
900
- if (isLoadingData) {
878
+ if (
879
+ isLoadingData ||
880
+ usersError ||
881
+ (selectedUserId && userGroupsError) ||
882
+ (hasSelectedGroups &&
883
+ canReadGroupLinkedProducts &&
884
+ groupProductsError &&
885
+ !isPermissionRequestError(groupProductsError)) ||
886
+ (selectedUserId &&
887
+ hasSelectedGroups &&
888
+ subscriptionInvoiceContextError &&
889
+ !isPermissionRequestError(subscriptionInvoiceContextError)) ||
890
+ (canReadInvoiceProducts &&
891
+ hasSelectedGroups &&
892
+ productsError &&
893
+ !isPermissionRequestError(productsError))
894
+ ) {
901
895
  return (
902
- <div className="flex items-center justify-center py-8">
903
- <div className="flex items-center gap-2">
904
- <Loader2 className="h-4 w-4 animate-spin" />
905
- <p className="text-muted-foreground text-sm">
906
- {t('ws-invoices.loading')}
907
- </p>
908
- </div>
909
- </div>
896
+ <InvoiceDataState
897
+ loading={isLoadingData || isLoadingSubscriptionData}
898
+ onRetry={() => {
899
+ void refetchUsers();
900
+ if (selectedUserId) void refetchUserGroups();
901
+ if (selectedUserId && hasSelectedGroups)
902
+ void refetchSubscriptionContext();
903
+ if (hasSelectedGroups && canReadGroupLinkedProducts)
904
+ void refetchGroupProducts();
905
+ if (canReadInvoiceProducts && hasSelectedGroups)
906
+ void refetchProducts();
907
+ }}
908
+ />
910
909
  );
911
910
  }
912
911
 
@@ -929,9 +928,6 @@ export function SubscriptionInvoice({
929
928
  isFetchingNextPage={isFetchingMoreCustomers}
930
929
  hasNextPage={hasMoreCustomers}
931
930
  onLoadMore={() => void fetchMoreCustomers()}
932
- errorMessage={
933
- usersError instanceof Error ? usersError.message : undefined
934
- }
935
931
  emptyMessage={t('ws-invoices.no_customers_found')}
936
932
  searchValue={customerSearch}
937
933
  onSearchChange={setCustomerSearch}