@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
@@ -1,9 +1,5 @@
1
1
  'use client';
2
2
 
3
- import {
4
- listFinanceInvoices,
5
- listPendingFinanceInvoices,
6
- } from '@tuturuuu/internal-api/finance';
7
3
  import { Button } from '@tuturuuu/ui/button';
8
4
  import {
9
5
  DialogClose,
@@ -28,182 +24,16 @@ import { useId, useState } from 'react';
28
24
  import { jsonToCSV } from 'react-papaparse';
29
25
  import { useWorkspaceConfig } from '../../../../hooks/use-workspace-config';
30
26
  import { XLSX } from '../../../../xlsx';
31
-
32
- /**
33
- * Shape of invoice data as exported to CSV/Excel
34
- * Flattened structure with complex objects decomposed into primitive fields
35
- */
36
- interface InvoiceExportRow {
37
- // Common fields
38
- id: string;
39
- ws_id: string;
40
- created_at: string;
41
- notice?: string;
42
- note?: string;
43
- price?: number | string;
44
- total_diff?: number | string;
45
-
46
- // Flattened customer fields
47
- customer_name?: string;
48
- customer_avatar_url?: string;
49
-
50
- // Flattened creator fields (created invoices only)
51
- creator_name?: string;
52
- creator_email?: string;
53
- creator_id?: string;
54
-
55
- // Flattened wallet fields (created invoices only)
56
- wallet_name?: string;
57
-
58
- // Pending invoice specific fields
59
- user_id?: string;
60
- user_name?: string;
61
- user_avatar_url?: string;
62
- group_id?: string;
63
- group_name?: string;
64
- months_owed?: string;
65
- attendance_days?: number;
66
- total_sessions?: number;
67
- potential_total?: number | string;
68
- }
69
-
70
- type PendingInvoiceExportData = {
71
- attendance_days: number;
72
- group_id: string;
73
- group_name: string;
74
- months_owed: string;
75
- potential_total: number;
76
- total_sessions: number;
77
- user_avatar_url: string;
78
- user_id: string;
79
- user_name: string;
80
- customer: {
81
- full_name: string;
82
- avatar_url: string;
83
- } | null;
84
- creator: null;
85
- wallet: null;
86
- };
87
-
88
- type CreatedInvoiceExportData = InvoiceExportRow & {
89
- customer?: {
90
- full_name?: string | null;
91
- avatar_url?: string | null;
92
- } | null;
93
- creator?: {
94
- display_name?: string | null;
95
- full_name?: string | null;
96
- email?: string | null;
97
- } | null;
98
- wallet?: {
99
- name?: string | null;
100
- } | null;
101
- };
102
-
103
- type PendingInvoiceApiRow = Omit<
104
- PendingInvoiceExportData,
105
- 'creator' | 'customer' | 'group_id' | 'group_name' | 'months_owed' | 'wallet'
106
- > & {
107
- group_id?: string | null;
108
- group_ids?: string[] | null;
109
- group_name?: string | null;
110
- group_names?: string[] | null;
111
- months_owed?: string | string[] | null;
112
- };
113
-
114
- function normalizeQueryArray(value?: string | string[]) {
115
- if (!value) return [];
116
- return (Array.isArray(value) ? value : [value]).filter(Boolean);
117
- }
118
-
119
- // Helper function to fetch pending invoices data for export
120
- async function getPendingInvoicesData(
121
- wsId: string,
122
- {
123
- page = '1',
124
- pageSize = '10',
125
- q,
126
- userIds,
127
- groupByUser = false,
128
- }: {
129
- page?: string;
130
- pageSize?: string;
131
- q?: string;
132
- userIds?: string | string[];
133
- groupByUser?: boolean;
134
- }
135
- ): Promise<{ data: PendingInvoiceExportData[]; count: number }> {
136
- const payload = await listPendingFinanceInvoices(wsId, {
137
- groupByUser,
138
- page,
139
- pageSize,
140
- q: q || '',
141
- userIds: normalizeQueryArray(userIds),
142
- });
143
-
144
- const rawData = (payload.data || []) as PendingInvoiceApiRow[];
145
- const totalCount = payload.count || 0;
146
-
147
- const transformedData = rawData.map((invoice): PendingInvoiceExportData => {
148
- const monthsOwed = Array.isArray(invoice.months_owed)
149
- ? invoice.months_owed.join(', ')
150
- : typeof invoice.months_owed === 'string'
151
- ? invoice.months_owed
152
- : '';
153
-
154
- const baseInvoice = {
155
- ...invoice,
156
- attendance_days: invoice.attendance_days ?? 0,
157
- months_owed: monthsOwed,
158
- potential_total: invoice.potential_total ?? 0,
159
- total_sessions: invoice.total_sessions ?? 0,
160
- user_avatar_url: invoice.user_avatar_url || '',
161
- user_id: invoice.user_id || '',
162
- user_name: invoice.user_name || '',
163
- };
164
-
165
- if (groupByUser) {
166
- const groupNames = Array.isArray(invoice.group_names)
167
- ? invoice.group_names.filter(Boolean)
168
- : [];
169
- const groupedName = groupNames.join(', ');
170
- const groupIdValue = Array.isArray(invoice.group_ids)
171
- ? invoice.group_ids.join(',')
172
- : '';
173
-
174
- return {
175
- ...baseInvoice,
176
- group_id: groupIdValue,
177
- group_name: groupedName,
178
- customer: {
179
- full_name: invoice.user_name || '',
180
- avatar_url: invoice.user_avatar_url || '',
181
- },
182
- creator: null,
183
- wallet: null,
184
- };
185
- }
186
-
187
- return {
188
- ...baseInvoice,
189
- group_id: invoice.group_id || '',
190
- group_name: invoice.group_name || '',
191
- customer: invoice.user_id
192
- ? {
193
- full_name: invoice.user_name || '',
194
- avatar_url: invoice.user_avatar_url || '',
195
- }
196
- : {
197
- full_name: invoice.group_name || '',
198
- avatar_url: '',
199
- },
200
- creator: null,
201
- wallet: null,
202
- };
203
- });
204
-
205
- return { data: transformedData, count: totalCount };
206
- }
27
+ import {
28
+ type CreatedInvoiceExportData,
29
+ getData,
30
+ getPendingInvoicesData,
31
+ type InvoiceExportRow,
32
+ } from './export-data';
33
+ import {
34
+ collectInvoiceExport,
35
+ IncompleteInvoiceExportError,
36
+ } from './export-pagination';
207
37
 
208
38
  export default function ExportDialogContent({
209
39
  wsId,
@@ -236,12 +66,16 @@ export default function ExportDialogContent({
236
66
  const filenameId = useId();
237
67
  const fileTypeId = useId();
238
68
 
239
- const { data: groupByUserConfig, isLoading: groupingLoading } =
240
- useWorkspaceConfig<string>(
241
- wsId,
242
- 'INVOICE_GROUP_PENDING_INVOICES_BY_USER',
243
- 'false'
244
- );
69
+ const {
70
+ data: groupByUserConfig,
71
+ isLoading: groupingLoading,
72
+ isError: groupingError,
73
+ refetch: retryGrouping,
74
+ } = useWorkspaceConfig<string>(
75
+ wsId,
76
+ 'INVOICE_GROUP_PENDING_INVOICES_BY_USER',
77
+ 'false'
78
+ );
245
79
 
246
80
  const groupByUser = groupByUserConfig === 'true';
247
81
 
@@ -279,73 +113,73 @@ export default function ExportDialogContent({
279
113
  };
280
114
 
281
115
  const handleExport = async () => {
116
+ if (
117
+ isExporting ||
118
+ (invoiceType === 'pending' && (groupingLoading || groupingError))
119
+ )
120
+ return;
282
121
  setIsExporting(true);
283
122
  setProgress(0);
284
123
  setExportError(null);
285
124
 
286
- const allData: InvoiceExportRow[] = [];
287
- let currentPage = 1;
288
- const pageSize = 1000;
289
-
290
125
  try {
291
- while (true) {
292
- const { data, count } =
293
- invoiceType === 'pending'
294
- ? await getPendingInvoicesData(wsId, {
295
- page: currentPage.toString(),
296
- pageSize: pageSize.toString(),
297
- q: searchParams.q,
298
- userIds: searchParams.userIds,
299
- groupByUser,
300
- })
301
- : await getData(wsId, {
302
- q: searchParams.q,
303
- page: currentPage.toString(),
304
- pageSize: pageSize.toString(),
305
- userIds: searchParams.userIds,
306
- walletId: searchParams.walletId,
307
- walletIds: searchParams.walletIds,
308
- start: searchParams.start,
309
- end: searchParams.end,
310
- });
311
-
312
- const flattenedData: InvoiceExportRow[] = data.map((invoice) => {
313
- // Destructure out complex objects to prevent [object Object] in exports
314
- const { customer, creator, wallet, ...rest } =
315
- invoice as CreatedInvoiceExportData;
316
-
317
- // Only include creator & wallet fields for created invoices
318
- if (invoiceType === 'created') {
319
- return {
320
- ...rest,
321
- customer_name: customer?.full_name || '',
322
- customer_avatar_url: customer?.avatar_url || '',
323
- creator_name:
324
- creator?.display_name ||
325
- creator?.full_name ||
326
- creator?.email ||
327
- '',
328
- creator_email: creator?.email || '',
329
- wallet_name: wallet?.name || '',
330
- } as InvoiceExportRow;
331
- }
332
-
333
- // For pending invoices, just return rest (user_name & user_avatar_url already included)
334
- return rest as unknown as InvoiceExportRow;
335
- });
336
-
337
- allData.push(...flattenedData);
338
-
339
- const totalPages = Math.max(1, Math.ceil(count / pageSize));
340
- const progressValue = (currentPage / totalPages) * 100;
341
- setProgress(progressValue);
342
-
343
- if (data.length < pageSize) {
344
- break;
126
+ const data = await collectInvoiceExport<CreatedInvoiceExportData>({
127
+ onProgress: setProgress,
128
+ getKey: (row) => {
129
+ if (invoiceType === 'created') return row.id;
130
+ if (!row.user_id || (!groupByUser && !row.group_id)) return '';
131
+ return groupByUser
132
+ ? row.user_id
133
+ : JSON.stringify([row.user_id, row.group_id]);
134
+ },
135
+ fetchPage: async (currentPage, pageSize) => {
136
+ const result =
137
+ invoiceType === 'pending'
138
+ ? await getPendingInvoicesData(wsId, {
139
+ page: currentPage.toString(),
140
+ pageSize: pageSize.toString(),
141
+ q: searchParams.q,
142
+ userIds: searchParams.userIds,
143
+ groupByUser,
144
+ })
145
+ : await getData(wsId, {
146
+ q: searchParams.q,
147
+ page: currentPage.toString(),
148
+ pageSize: pageSize.toString(),
149
+ userIds: searchParams.userIds,
150
+ walletId: searchParams.walletId,
151
+ walletIds: searchParams.walletIds,
152
+ start: searchParams.start,
153
+ end: searchParams.end,
154
+ });
155
+
156
+ return result as { data: CreatedInvoiceExportData[]; count: number };
157
+ },
158
+ });
159
+ const allData: InvoiceExportRow[] = data.map((invoice) => {
160
+ // Destructure out complex objects to prevent [object Object] in exports
161
+ const { customer, creator, wallet, ...rest } =
162
+ invoice as CreatedInvoiceExportData;
163
+
164
+ // Only include creator & wallet fields for created invoices
165
+ if (invoiceType === 'created') {
166
+ return {
167
+ ...rest,
168
+ customer_name: customer?.full_name || '',
169
+ customer_avatar_url: customer?.avatar_url || '',
170
+ creator_name:
171
+ creator?.display_name ||
172
+ creator?.full_name ||
173
+ creator?.email ||
174
+ '',
175
+ creator_email: creator?.email || '',
176
+ wallet_name: wallet?.name || '',
177
+ } as InvoiceExportRow;
345
178
  }
346
179
 
347
- currentPage++;
348
- }
180
+ // For pending invoices, just return rest (user_name & user_avatar_url already included)
181
+ return rest as unknown as InvoiceExportRow;
182
+ });
349
183
 
350
184
  setProgress(100);
351
185
 
@@ -362,8 +196,12 @@ export default function ExportDialogContent({
362
196
  }
363
197
 
364
198
  toast.success(t('common.export-success'));
365
- } catch {
366
- const errorMessage = t('common.export-error');
199
+ } catch (error) {
200
+ const errorMessage = t(
201
+ error instanceof IncompleteInvoiceExportError
202
+ ? 'ws-invoices.export_incomplete'
203
+ : 'common.export-error'
204
+ );
367
205
  setExportError(errorMessage);
368
206
  toast.error(errorMessage);
369
207
  } finally {
@@ -390,6 +228,19 @@ export default function ExportDialogContent({
390
228
  </DialogHeader>
391
229
 
392
230
  <div className="grid gap-1">
231
+ {invoiceType === 'pending' && (
232
+ <p className="text-muted-foreground text-sm">
233
+ {t('ws-invoices.export_pending_scope')}
234
+ </p>
235
+ )}
236
+ {invoiceType === 'pending' && groupingError && (
237
+ <div role="alert">
238
+ <p>{t('ws-invoices.error_loading')}</p>
239
+ <Button variant="outline" onClick={() => retryGrouping()}>
240
+ {t('common.retry')}
241
+ </Button>
242
+ </div>
243
+ )}
393
244
  <div className="grid w-full max-w-sm items-center gap-2">
394
245
  <Label htmlFor={filenameId}>{t('common.file-name')}</Label>
395
246
  <Input
@@ -427,7 +278,10 @@ export default function ExportDialogContent({
427
278
  )}
428
279
 
429
280
  {exportError && (
430
- <div className="mt-2 rounded-md bg-destructive/10 p-3 text-destructive text-sm">
281
+ <div
282
+ role="alert"
283
+ className="mt-2 rounded-md bg-destructive/10 p-3 text-destructive text-sm"
284
+ >
431
285
  {exportError}
432
286
  </div>
433
287
  )}
@@ -435,13 +289,16 @@ export default function ExportDialogContent({
435
289
 
436
290
  <DialogFooter className="justify-between">
437
291
  <DialogClose asChild>
438
- <Button type="button" variant="secondary">
292
+ <Button type="button" variant="secondary" disabled={isExporting}>
439
293
  {t('common.cancel')}
440
294
  </Button>
441
295
  </DialogClose>
442
296
  <Button
443
297
  onClick={handleExport}
444
- disabled={isExporting || groupingLoading}
298
+ disabled={
299
+ isExporting ||
300
+ (invoiceType === 'pending' && (groupingLoading || groupingError))
301
+ }
445
302
  >
446
303
  {isExporting ? t('common.loading') : t('common.export')}
447
304
  </Button>
@@ -449,44 +306,3 @@ export default function ExportDialogContent({
449
306
  </>
450
307
  );
451
308
  }
452
-
453
- async function getData(
454
- wsId: string,
455
- {
456
- q,
457
- page = '1',
458
- pageSize = '10',
459
- start,
460
- end,
461
- userIds,
462
- walletId,
463
- walletIds,
464
- }: {
465
- q?: string;
466
- page?: string;
467
- pageSize?: string;
468
- start?: string;
469
- end?: string;
470
- userIds?: string | string[];
471
- walletId?: string;
472
- walletIds?: string | string[];
473
- }
474
- ) {
475
- const walletFilterIds = [
476
- ...(walletId ? [walletId] : []),
477
- ...normalizeQueryArray(walletIds),
478
- ];
479
-
480
- return (await listFinanceInvoices(wsId, {
481
- end,
482
- page,
483
- pageSize,
484
- q,
485
- start,
486
- userIds: normalizeQueryArray(userIds),
487
- walletIds: walletFilterIds,
488
- })) as {
489
- data: CreatedInvoiceExportData[];
490
- count: number;
491
- };
492
- }
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ collectInvoiceExport,
4
+ IncompleteInvoiceExportError,
5
+ } from './export-pagination';
6
+
7
+ const getKey = (row: { id: string }) => row.id;
8
+ const rows = (n: number) =>
9
+ Array.from({ length: n }, (_, i) => ({
10
+ id: `invoice-${i}`,
11
+ group: `class-${i % 7}`,
12
+ }));
13
+
14
+ describe('complete invoice exports', () => {
15
+ it.each([0, 1, 999, 1000, 1001, 2000, 2507])(
16
+ 'preserves every row and class for %i invoices',
17
+ async (count) => {
18
+ const data = rows(count);
19
+ const fetchPage = vi.fn(async (page: number, pageSize: number) => ({
20
+ count,
21
+ data: data.slice((page - 1) * pageSize, page * pageSize),
22
+ }));
23
+ const progress = vi.fn();
24
+ const actual = await collectInvoiceExport({
25
+ fetchPage,
26
+ getKey,
27
+ onProgress: progress,
28
+ });
29
+ expect(actual).toEqual(data);
30
+ expect(new Set(actual.map((row) => row.group))).toEqual(
31
+ new Set(data.map((row) => row.group))
32
+ );
33
+ expect(fetchPage).toHaveBeenCalledTimes(
34
+ Math.max(1, Math.ceil(count / 1000))
35
+ );
36
+ expect(progress).toHaveBeenLastCalledWith(100);
37
+ expect(
38
+ progress.mock.calls.every(([value]) => value >= 0 && value <= 100)
39
+ ).toBe(true);
40
+ }
41
+ );
42
+
43
+ it('keeps one student in multiple classes as distinct debt rows', async () => {
44
+ const data = [
45
+ { user: 'student', group: 'class-a' },
46
+ { user: 'student', group: 'class-b' },
47
+ ];
48
+ expect(
49
+ await collectInvoiceExport({
50
+ fetchPage: async () => ({ count: 2, data }),
51
+ getKey: (row) => JSON.stringify([row.user, row.group]),
52
+ })
53
+ ).toEqual(data);
54
+ });
55
+
56
+ it.each([
57
+ {
58
+ name: 'a truncated first page',
59
+ pages: [{ count: 1500, data: rows(100) }],
60
+ },
61
+ { name: 'an empty first page with debt', pages: [{ count: 20, data: [] }] },
62
+ {
63
+ name: 'an empty later page',
64
+ pages: [
65
+ { count: 1001, data: rows(1000) },
66
+ { count: 1001, data: [] },
67
+ ],
68
+ },
69
+ {
70
+ name: 'a shrinking dataset',
71
+ pages: [
72
+ { count: 1001, data: rows(1000) },
73
+ { count: 1000, data: [] },
74
+ ],
75
+ },
76
+ {
77
+ name: 'a growing dataset',
78
+ pages: [
79
+ { count: 1001, data: rows(1000) },
80
+ { count: 1002, data: rows(2) },
81
+ ],
82
+ },
83
+ {
84
+ name: 'duplicate page boundaries',
85
+ pages: [
86
+ { count: 1001, data: rows(1000) },
87
+ { count: 1001, data: [{ id: 'invoice-999' }] },
88
+ ],
89
+ },
90
+ {
91
+ name: 'duplicate rows within a page',
92
+ pages: [{ count: 2, data: [{ id: 'same' }, { id: 'same' }] }],
93
+ },
94
+ { name: 'rows beyond the count', pages: [{ count: 0, data: rows(1) }] },
95
+ { name: 'missing identities', pages: [{ count: 1, data: [{ id: '' }] }] },
96
+ { name: 'a missing count', pages: [{ data: [] }] },
97
+ { name: 'a missing row array', pages: [{ count: 0 }] },
98
+ { name: 'a negative count', pages: [{ count: -1, data: [] }] },
99
+ { name: 'a fractional count', pages: [{ count: 0.5, data: [] }] },
100
+ { name: 'an infinite count', pages: [{ count: Infinity, data: [] }] },
101
+ ])('rejects $name instead of producing a partial file', async ({ pages }) => {
102
+ const fetchPage = vi.fn(
103
+ async () => pages.shift() as { count: number; data: { id: string }[] }
104
+ );
105
+ await expect(
106
+ collectInvoiceExport({ fetchPage, getKey })
107
+ ).rejects.toBeInstanceOf(IncompleteInvoiceExportError);
108
+ });
109
+
110
+ it('propagates later request failures without returning partial rows', async () => {
111
+ const error = new Error('Request timed out');
112
+ const fetchPage = vi
113
+ .fn()
114
+ .mockResolvedValueOnce({ count: 1001, data: rows(1000) })
115
+ .mockRejectedValueOnce(error);
116
+ await expect(collectInvoiceExport({ fetchPage, getKey })).rejects.toBe(
117
+ error
118
+ );
119
+ });
120
+
121
+ it.each([0, -1, 1.5, Infinity])(
122
+ 'rejects invalid page size %s',
123
+ async (pageSize) => {
124
+ const fetchPage = vi.fn();
125
+ await expect(
126
+ collectInvoiceExport({ fetchPage, getKey, pageSize })
127
+ ).rejects.toThrow(RangeError);
128
+ expect(fetchPage).not.toHaveBeenCalled();
129
+ }
130
+ );
131
+ });
@@ -0,0 +1,66 @@
1
+ export class IncompleteInvoiceExportError extends Error {
2
+ constructor() {
3
+ super(
4
+ 'Invoice export changed or returned incomplete data. Retry the export.'
5
+ );
6
+ this.name = 'IncompleteInvoiceExportError';
7
+ }
8
+ }
9
+
10
+ /** Never turn truncated, overlapping, or changing pages into a successful file. */
11
+ export async function collectInvoiceExport<T>({
12
+ fetchPage,
13
+ getKey,
14
+ onProgress,
15
+ pageSize = 1000,
16
+ }: {
17
+ fetchPage: (
18
+ page: number,
19
+ pageSize: number
20
+ ) => Promise<{
21
+ data: T[];
22
+ count: number;
23
+ }>;
24
+ getKey: (row: T) => string;
25
+ onProgress?: (progress: number) => void;
26
+ pageSize?: number;
27
+ }): Promise<T[]> {
28
+ if (!Number.isSafeInteger(pageSize) || pageSize < 1) {
29
+ throw new RangeError('Invalid export page size');
30
+ }
31
+
32
+ const rows: T[] = [];
33
+ const keys = new Set<string>();
34
+ let expectedCount: number | undefined;
35
+
36
+ for (let page = 1; ; page++) {
37
+ const result = await fetchPage(page, pageSize);
38
+ if (
39
+ !result ||
40
+ !Array.isArray(result.data) ||
41
+ !Number.isSafeInteger(result.count) ||
42
+ result.count < 0 ||
43
+ (expectedCount !== undefined && result.count !== expectedCount)
44
+ ) {
45
+ throw new IncompleteInvoiceExportError();
46
+ }
47
+ expectedCount = result.count;
48
+
49
+ const expectedLength = Math.min(pageSize, expectedCount - rows.length);
50
+ if (result.data.length !== expectedLength) {
51
+ throw new IncompleteInvoiceExportError();
52
+ }
53
+
54
+ for (const row of result.data) {
55
+ const key = getKey(row);
56
+ if (!key || keys.has(key)) throw new IncompleteInvoiceExportError();
57
+ keys.add(key);
58
+ rows.push(row);
59
+ }
60
+
61
+ onProgress?.(
62
+ expectedCount === 0 ? 100 : (rows.length / expectedCount) * 100
63
+ );
64
+ if (rows.length === expectedCount) return rows;
65
+ }
66
+ }