@tuturuuu/ui 0.33.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.
- package/CHANGELOG.md +13 -0
- package/package.json +5 -5
- package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
- package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
- package/src/components/ui/finance/invoices/export-data.ts +229 -0
- package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
- package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
- package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
- package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
- package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +2 -0
- package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +3 -6
- package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
- package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
- package/src/components/ui/finance/invoices/utils.ts +48 -106
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
- package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
- package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
- package/src/components/ui/legacy/calendar/working-location.ts +35 -0
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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={
|
|
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
|
+
}
|