@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
@@ -0,0 +1,229 @@
1
+ import {
2
+ listFinanceInvoices,
3
+ listPendingFinanceInvoices,
4
+ } from '@tuturuuu/internal-api/finance';
5
+ import { IncompleteInvoiceExportError } from './export-pagination';
6
+
7
+ /**
8
+ * Shape of invoice data as exported to CSV/Excel
9
+ * Flattened structure with complex objects decomposed into primitive fields
10
+ */
11
+ export interface InvoiceExportRow {
12
+ // Common fields
13
+ id: string;
14
+ ws_id: string;
15
+ created_at: string;
16
+ notice?: string;
17
+ note?: string;
18
+ price?: number | string;
19
+ total_diff?: number | string;
20
+
21
+ // Flattened customer fields
22
+ customer_name?: string;
23
+ customer_avatar_url?: string;
24
+
25
+ // Flattened creator fields (created invoices only)
26
+ creator_name?: string;
27
+ creator_email?: string;
28
+ creator_id?: string;
29
+
30
+ // Flattened wallet fields (created invoices only)
31
+ wallet_name?: string;
32
+
33
+ // Pending invoice specific fields
34
+ user_id?: string;
35
+ user_name?: string;
36
+ user_avatar_url?: string;
37
+ group_id?: string;
38
+ group_name?: string;
39
+ months_owed?: string;
40
+ attendance_days?: number;
41
+ total_sessions?: number;
42
+ potential_total?: number | string;
43
+ }
44
+
45
+ type PendingInvoiceExportData = {
46
+ attendance_days: number;
47
+ group_id: string;
48
+ group_name: string;
49
+ months_owed: string;
50
+ potential_total: number;
51
+ total_sessions: number;
52
+ user_avatar_url: string;
53
+ user_id: string;
54
+ user_name: string;
55
+ customer: {
56
+ full_name: string;
57
+ avatar_url: string;
58
+ } | null;
59
+ creator: null;
60
+ wallet: null;
61
+ };
62
+
63
+ export type CreatedInvoiceExportData = InvoiceExportRow & {
64
+ customer?: {
65
+ full_name?: string | null;
66
+ avatar_url?: string | null;
67
+ } | null;
68
+ creator?: {
69
+ display_name?: string | null;
70
+ full_name?: string | null;
71
+ email?: string | null;
72
+ } | null;
73
+ wallet?: {
74
+ name?: string | null;
75
+ } | null;
76
+ };
77
+
78
+ type PendingInvoiceApiRow = Omit<
79
+ PendingInvoiceExportData,
80
+ 'creator' | 'customer' | 'group_id' | 'group_name' | 'months_owed' | 'wallet'
81
+ > & {
82
+ group_id?: string | null;
83
+ group_ids?: string[] | null;
84
+ group_name?: string | null;
85
+ group_names?: string[] | null;
86
+ months_owed?: string | string[] | null;
87
+ };
88
+
89
+ function normalizeQueryArray(value?: string | string[]) {
90
+ if (!value) return [];
91
+ return (Array.isArray(value) ? value : [value]).filter(Boolean);
92
+ }
93
+
94
+ // Helper function to fetch pending invoices data for export
95
+ export async function getPendingInvoicesData(
96
+ wsId: string,
97
+ {
98
+ page = '1',
99
+ pageSize = '10',
100
+ q,
101
+ userIds,
102
+ groupByUser = false,
103
+ }: {
104
+ page?: string;
105
+ pageSize?: string;
106
+ q?: string;
107
+ userIds?: string | string[];
108
+ groupByUser?: boolean;
109
+ }
110
+ ): Promise<{ data: PendingInvoiceExportData[]; count: number }> {
111
+ const payload = await listPendingFinanceInvoices(wsId, {
112
+ groupByUser,
113
+ page,
114
+ pageSize,
115
+ q: q || '',
116
+ userIds: normalizeQueryArray(userIds),
117
+ });
118
+
119
+ if (
120
+ !Array.isArray(payload.data) ||
121
+ !Number.isSafeInteger(payload.count) ||
122
+ payload.count < 0
123
+ ) {
124
+ throw new IncompleteInvoiceExportError();
125
+ }
126
+ const rawData = payload.data as PendingInvoiceApiRow[];
127
+ const totalCount = payload.count;
128
+
129
+ const transformedData = rawData.map((invoice): PendingInvoiceExportData => {
130
+ const monthsOwed = Array.isArray(invoice.months_owed)
131
+ ? invoice.months_owed.join(', ')
132
+ : typeof invoice.months_owed === 'string'
133
+ ? invoice.months_owed
134
+ : '';
135
+
136
+ const baseInvoice = {
137
+ ...invoice,
138
+ attendance_days: invoice.attendance_days ?? 0,
139
+ months_owed: monthsOwed,
140
+ potential_total: invoice.potential_total ?? 0,
141
+ total_sessions: invoice.total_sessions ?? 0,
142
+ user_avatar_url: invoice.user_avatar_url || '',
143
+ user_id: invoice.user_id || '',
144
+ user_name: invoice.user_name || '',
145
+ };
146
+
147
+ if (groupByUser) {
148
+ const groupNames = Array.isArray(invoice.group_names)
149
+ ? invoice.group_names.filter(Boolean)
150
+ : [];
151
+ const groupedName = groupNames.join(', ');
152
+ const groupIdValue = Array.isArray(invoice.group_ids)
153
+ ? invoice.group_ids.join(',')
154
+ : '';
155
+
156
+ return {
157
+ ...baseInvoice,
158
+ group_id: groupIdValue,
159
+ group_name: groupedName,
160
+ customer: {
161
+ full_name: invoice.user_name || '',
162
+ avatar_url: invoice.user_avatar_url || '',
163
+ },
164
+ creator: null,
165
+ wallet: null,
166
+ };
167
+ }
168
+
169
+ return {
170
+ ...baseInvoice,
171
+ group_id: invoice.group_id || '',
172
+ group_name: invoice.group_name || '',
173
+ customer: invoice.user_id
174
+ ? {
175
+ full_name: invoice.user_name || '',
176
+ avatar_url: invoice.user_avatar_url || '',
177
+ }
178
+ : {
179
+ full_name: invoice.group_name || '',
180
+ avatar_url: '',
181
+ },
182
+ creator: null,
183
+ wallet: null,
184
+ };
185
+ });
186
+
187
+ return { data: transformedData, count: totalCount };
188
+ }
189
+
190
+ export async function getData(
191
+ wsId: string,
192
+ {
193
+ q,
194
+ page = '1',
195
+ pageSize = '10',
196
+ start,
197
+ end,
198
+ userIds,
199
+ walletId,
200
+ walletIds,
201
+ }: {
202
+ q?: string;
203
+ page?: string;
204
+ pageSize?: string;
205
+ start?: string;
206
+ end?: string;
207
+ userIds?: string | string[];
208
+ walletId?: string;
209
+ walletIds?: string | string[];
210
+ }
211
+ ) {
212
+ const walletFilterIds = [
213
+ ...(walletId ? [walletId] : []),
214
+ ...normalizeQueryArray(walletIds),
215
+ ];
216
+
217
+ return (await listFinanceInvoices(wsId, {
218
+ end,
219
+ page,
220
+ pageSize,
221
+ q,
222
+ start,
223
+ userIds: normalizeQueryArray(userIds),
224
+ walletIds: walletFilterIds,
225
+ })) as {
226
+ data: CreatedInvoiceExportData[];
227
+ count: number;
228
+ };
229
+ }
@@ -0,0 +1,245 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import type { PropsWithChildren } from 'react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import ExportDialogContent from './export-dialog-content';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ listPending: vi.fn(),
8
+ listCreated: vi.fn(),
9
+ success: vi.fn(),
10
+ error: vi.fn(),
11
+ retry: vi.fn(),
12
+ sheet: vi.fn(),
13
+ csv: vi.fn(),
14
+ click: vi.fn(),
15
+ config: { data: 'false', isLoading: false, isError: false },
16
+ }));
17
+ vi.mock('@tuturuuu/internal-api/finance', () => ({
18
+ listPendingFinanceInvoices: mocks.listPending,
19
+ listFinanceInvoices: mocks.listCreated,
20
+ }));
21
+ vi.mock('../../../../hooks/use-workspace-config', () => ({
22
+ useWorkspaceConfig: () => ({ ...mocks.config, refetch: mocks.retry }),
23
+ }));
24
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
25
+ vi.mock('@tuturuuu/ui/sonner', () => ({
26
+ toast: { success: mocks.success, error: mocks.error },
27
+ }));
28
+ vi.mock('react-papaparse', () => ({ jsonToCSV: mocks.csv }));
29
+ vi.mock('../../../../xlsx', () => ({
30
+ XLSX: {
31
+ utils: {
32
+ json_to_sheet: mocks.sheet,
33
+ book_new: vi.fn(),
34
+ book_append_sheet: vi.fn(),
35
+ },
36
+ write: () => new Uint8Array([1]),
37
+ },
38
+ }));
39
+ vi.mock('@tuturuuu/ui/dialog', () => {
40
+ const Wrapper = ({ children }: PropsWithChildren) => <div>{children}</div>;
41
+ return {
42
+ DialogClose: Wrapper,
43
+ DialogDescription: Wrapper,
44
+ DialogFooter: Wrapper,
45
+ DialogHeader: Wrapper,
46
+ DialogTitle: Wrapper,
47
+ };
48
+ });
49
+ vi.mock('@tuturuuu/ui/select', () => ({
50
+ Select: ({
51
+ onValueChange,
52
+ disabled,
53
+ }: {
54
+ onValueChange: (value: string) => void;
55
+ disabled?: boolean;
56
+ }) => (
57
+ <select
58
+ aria-label="Format"
59
+ disabled={disabled}
60
+ onChange={(event) => onValueChange(event.target.value)}
61
+ >
62
+ <option value="excel">Excel</option>
63
+ <option value="csv">CSV</option>
64
+ </select>
65
+ ),
66
+ SelectContent: () => null,
67
+ SelectItem: () => null,
68
+ SelectTrigger: () => null,
69
+ SelectValue: () => null,
70
+ }));
71
+
72
+ const pendingRows = (n: number) =>
73
+ Array.from({ length: n }, (_, i) => ({
74
+ user_id: `student-${i}`,
75
+ user_name: `Student ${i}`,
76
+ group_id: `class-${i % 3}`,
77
+ group_name: `Class ${i % 3}`,
78
+ months_owed: ['2026-08', '2026-09'],
79
+ attendance_days: 0,
80
+ total_sessions: 8,
81
+ potential_total: 800,
82
+ }));
83
+ const props = {
84
+ wsId: 'ws',
85
+ exportType: 'invoices',
86
+ invoiceType: 'pending' as const,
87
+ searchParams: { q: 'Class', userIds: ['student-filter'] },
88
+ };
89
+ const exportButton = () =>
90
+ screen.getByRole('button', { name: 'common.export' });
91
+
92
+ beforeEach(() => {
93
+ vi.clearAllMocks();
94
+ mocks.config = { data: 'false', isLoading: false, isError: false };
95
+ mocks.listPending.mockResolvedValue({ count: 1, data: pendingRows(1) });
96
+ mocks.listCreated.mockResolvedValue({
97
+ count: 1,
98
+ data: [
99
+ {
100
+ id: 'invoice',
101
+ ws_id: 'ws',
102
+ created_at: '2026-09-01',
103
+ customer: { full_name: 'Student' },
104
+ wallet: { name: 'Cash' },
105
+ },
106
+ ],
107
+ });
108
+ mocks.csv.mockReturnValue('csv');
109
+ URL.createObjectURL = vi.fn(() => 'blob:test');
110
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
111
+ mocks.click
112
+ );
113
+ });
114
+
115
+ describe('invoice export download boundary', () => {
116
+ it.each(['excel', 'csv'])(
117
+ 'downloads every class across multiple pages as %s with filters preserved',
118
+ async (format) => {
119
+ const rows = pendingRows(1001);
120
+ mocks.listPending.mockImplementation(async (_ws, query) => ({
121
+ count: rows.length,
122
+ data: rows.slice(
123
+ (Number(query.page) - 1) * 1000,
124
+ Number(query.page) * 1000
125
+ ),
126
+ }));
127
+ render(<ExportDialogContent {...props} />);
128
+ fireEvent.change(screen.getByLabelText('Format'), {
129
+ target: { value: format },
130
+ });
131
+ fireEvent.click(exportButton());
132
+ await waitFor(() => expect(mocks.success).toHaveBeenCalled());
133
+ const exported = (format === 'excel' ? mocks.sheet : mocks.csv).mock
134
+ .calls[0]?.[0];
135
+ expect(exported).toHaveLength(1001);
136
+ expect(
137
+ new Set(exported.map((row: { group_id: string }) => row.group_id)).size
138
+ ).toBe(3);
139
+ expect(exported[0].months_owed).toBe('2026-08, 2026-09');
140
+ expect(mocks.listPending).toHaveBeenLastCalledWith(
141
+ 'ws',
142
+ expect.objectContaining({
143
+ page: '2',
144
+ pageSize: '1000',
145
+ q: 'Class',
146
+ userIds: ['student-filter'],
147
+ groupByUser: false,
148
+ })
149
+ );
150
+ expect(mocks.click).toHaveBeenCalledTimes(1);
151
+ }
152
+ );
153
+
154
+ it('preserves grouped class names and one row per student', async () => {
155
+ mocks.config.data = 'true';
156
+ mocks.listPending.mockResolvedValue({
157
+ count: 1,
158
+ data: [
159
+ {
160
+ ...pendingRows(1)[0],
161
+ group_ids: ['a', 'b'],
162
+ group_names: ['Class A', 'Class B'],
163
+ },
164
+ ],
165
+ });
166
+ render(<ExportDialogContent {...props} />);
167
+ fireEvent.click(exportButton());
168
+ await waitFor(() => expect(mocks.sheet).toHaveBeenCalled());
169
+ expect(mocks.sheet.mock.calls[0]?.[0][0]).toMatchObject({
170
+ group_id: 'a,b',
171
+ group_name: 'Class A, Class B',
172
+ });
173
+ expect(mocks.listPending).toHaveBeenCalledWith(
174
+ 'ws',
175
+ expect.objectContaining({ groupByUser: true })
176
+ );
177
+ });
178
+
179
+ it.each([
180
+ { name: 'short data', payload: { count: 2, data: pendingRows(1) } },
181
+ {
182
+ name: 'duplicate debt',
183
+ payload: { count: 2, data: [...pendingRows(1), ...pendingRows(1)] },
184
+ },
185
+ { name: 'missing rows', payload: { count: 0 } },
186
+ { name: 'missing count', payload: { data: [] } },
187
+ ])('prevents both download and success for $name', async ({ payload }) => {
188
+ mocks.listPending.mockResolvedValue(payload);
189
+ render(<ExportDialogContent {...props} />);
190
+ fireEvent.click(exportButton());
191
+ await waitFor(() =>
192
+ expect(screen.getByRole('alert')).toHaveTextContent(
193
+ 'ws-invoices.export_incomplete'
194
+ )
195
+ );
196
+ expect(mocks.click).not.toHaveBeenCalled();
197
+ expect(mocks.sheet).not.toHaveBeenCalled();
198
+ expect(mocks.success).not.toHaveBeenCalled();
199
+ expect(exportButton()).toBeEnabled();
200
+ });
201
+
202
+ it('allows a clean retry after a later-page failure without retaining prior rows', async () => {
203
+ mocks.listPending
204
+ .mockResolvedValueOnce({ count: 1001, data: pendingRows(1000) })
205
+ .mockRejectedValueOnce(new Error('Timeout'));
206
+ render(<ExportDialogContent {...props} />);
207
+ fireEvent.click(exportButton());
208
+ await waitFor(() => expect(mocks.error).toHaveBeenCalled());
209
+ expect(mocks.click).not.toHaveBeenCalled();
210
+ fireEvent.click(exportButton());
211
+ await waitFor(() => expect(mocks.success).toHaveBeenCalled());
212
+ expect(mocks.sheet.mock.calls[0]?.[0]).toHaveLength(1);
213
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
214
+ });
215
+
216
+ it('blocks pending export when grouping configuration fails and exposes retry', () => {
217
+ mocks.config.isError = true;
218
+ render(<ExportDialogContent {...props} />);
219
+ expect(exportButton()).toBeDisabled();
220
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
221
+ expect(mocks.retry).toHaveBeenCalled();
222
+ expect(mocks.listPending).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it('does not block created exports on an unrelated grouping configuration error', async () => {
226
+ mocks.config.isError = true;
227
+ render(
228
+ <ExportDialogContent
229
+ {...props}
230
+ invoiceType="created"
231
+ searchParams={{ walletId: 'cash', start: '2026-09-01' }}
232
+ />
233
+ );
234
+ fireEvent.click(exportButton());
235
+ await waitFor(() => expect(mocks.success).toHaveBeenCalled());
236
+ expect(mocks.listCreated).toHaveBeenCalledWith(
237
+ 'ws',
238
+ expect.objectContaining({ walletIds: ['cash'], start: '2026-09-01' })
239
+ );
240
+ expect(mocks.sheet.mock.calls[0]?.[0][0]).toMatchObject({
241
+ customer_name: 'Student',
242
+ wallet_name: 'Cash',
243
+ });
244
+ });
245
+ });