@striae-org/striae 5.4.5 → 5.5.1
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/app/components/actions/export-audit-pdf.ts +438 -0
- package/app/components/audit/user-audit-viewer.tsx +155 -97
- package/app/components/audit/user-audit.module.css +17 -0
- package/app/components/audit/viewer/audit-viewer-header.tsx +16 -0
- package/app/components/sidebar/notes/class-details/class-details-modal.tsx +3 -3
- package/app/components/sidebar/notes/notes-editor-form.tsx +244 -12
- package/app/components/sidebar/notes/notes-editor-modal.tsx +181 -2
- package/app/components/sidebar/notes/notes.module.css +84 -1
- package/app/components/user/user.module.css +1 -1
- package/package.json +8 -8
- package/scripts/update-markdown-versions.cjs +58 -1
- package/workers/audit-worker/package.json +17 -18
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +17 -18
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +17 -18
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +18 -19
- package/workers/pdf-worker/src/audit-trail-report.ts +253 -0
- package/workers/pdf-worker/src/formats/format-striae.ts +16 -14
- package/workers/pdf-worker/src/pdf-worker.example.ts +8 -0
- package/workers/pdf-worker/src/report-types.ts +15 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +17 -18
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import { auditService } from '~/services/audit';
|
|
3
|
+
import type { ValidationAuditEntry } from '~/types';
|
|
4
|
+
import { getCaseData } from '~/utils/data';
|
|
5
|
+
import { canAccessCase } from '~/utils/data/permissions';
|
|
6
|
+
import { fetchPdfApi } from '~/utils/api';
|
|
7
|
+
import type { ToastType } from '~/components/toast/toast';
|
|
8
|
+
|
|
9
|
+
interface ExportAuditPdfParams {
|
|
10
|
+
user: User;
|
|
11
|
+
caseNumber: string;
|
|
12
|
+
userCompany?: string;
|
|
13
|
+
userFirstName?: string;
|
|
14
|
+
userLastName?: string;
|
|
15
|
+
userBadgeId?: string;
|
|
16
|
+
setIsExportingPDF: (isExporting: boolean) => void;
|
|
17
|
+
setToastType: (type: ToastType) => void;
|
|
18
|
+
setToastMessage: (message: string) => void;
|
|
19
|
+
setShowToast: (show: boolean) => void;
|
|
20
|
+
setToastDuration?: (duration: number) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AuditTrailPdfPayload {
|
|
24
|
+
reportMode: 'audit-trail';
|
|
25
|
+
caseNumber: string;
|
|
26
|
+
exportedAt: string;
|
|
27
|
+
exportRangeStart: string;
|
|
28
|
+
exportRangeEnd: string;
|
|
29
|
+
chunkIndex: number;
|
|
30
|
+
totalChunks: number;
|
|
31
|
+
totalEntries: number;
|
|
32
|
+
includeRawJsonAppendix: boolean;
|
|
33
|
+
entries: ValidationAuditEntry[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MAX_AUDIT_ENTRIES_PER_PDF = 200;
|
|
37
|
+
const AUDIT_FETCH_WINDOW_DAYS = 30;
|
|
38
|
+
|
|
39
|
+
const formatShortDate = (date: Date): string => {
|
|
40
|
+
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
41
|
+
const day = date.getDate().toString().padStart(2, '0');
|
|
42
|
+
const year = date.getFullYear().toString();
|
|
43
|
+
return `${month}/${day}/${year}`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const formatDateStamp = (date: Date): string => {
|
|
47
|
+
const year = date.getFullYear().toString();
|
|
48
|
+
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
49
|
+
const day = date.getDate().toString().padStart(2, '0');
|
|
50
|
+
return `${year}${month}${day}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const chunkEntries = (entries: ValidationAuditEntry[]): ValidationAuditEntry[][] => {
|
|
54
|
+
if (entries.length === 0) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const chunks: ValidationAuditEntry[][] = [];
|
|
59
|
+
for (let offset = 0; offset < entries.length; offset += MAX_AUDIT_ENTRIES_PER_PDF) {
|
|
60
|
+
chunks.push(entries.slice(offset, offset + MAX_AUDIT_ENTRIES_PER_PDF));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return chunks;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const downloadPdfBlob = (blob: Blob, filename: string): void => {
|
|
67
|
+
const downloadUrl = URL.createObjectURL(blob);
|
|
68
|
+
const anchor = document.createElement('a');
|
|
69
|
+
anchor.href = downloadUrl;
|
|
70
|
+
anchor.download = filename;
|
|
71
|
+
document.body.appendChild(anchor);
|
|
72
|
+
anchor.click();
|
|
73
|
+
document.body.removeChild(anchor);
|
|
74
|
+
URL.revokeObjectURL(downloadUrl);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const normalizeIsoDate = (value?: string): string | null => {
|
|
78
|
+
if (!value) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parsed = new Date(value);
|
|
83
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return parsed.toISOString();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const toUtcDayStart = (value: string): Date => {
|
|
91
|
+
const parsed = new Date(value);
|
|
92
|
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate(), 0, 0, 0, 0));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const toUtcDayEnd = (value: string): Date => {
|
|
96
|
+
const parsed = new Date(value);
|
|
97
|
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate(), 23, 59, 59, 999));
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const addUtcDays = (date: Date, days: number): Date => {
|
|
101
|
+
return new Date(Date.UTC(
|
|
102
|
+
date.getUTCFullYear(),
|
|
103
|
+
date.getUTCMonth(),
|
|
104
|
+
date.getUTCDate() + days,
|
|
105
|
+
date.getUTCHours(),
|
|
106
|
+
date.getUTCMinutes(),
|
|
107
|
+
date.getUTCSeconds(),
|
|
108
|
+
date.getUTCMilliseconds()
|
|
109
|
+
));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const getAuditEntryIdentity = (entry: ValidationAuditEntry): string => {
|
|
113
|
+
return [
|
|
114
|
+
entry.timestamp,
|
|
115
|
+
entry.userId,
|
|
116
|
+
entry.action,
|
|
117
|
+
entry.result,
|
|
118
|
+
entry.details.caseNumber || '',
|
|
119
|
+
entry.details.fileName || '',
|
|
120
|
+
entry.details.confirmationId || ''
|
|
121
|
+
].join('|');
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const fetchAllCaseEntriesForExport = async (
|
|
125
|
+
user: User,
|
|
126
|
+
caseNumber: string,
|
|
127
|
+
caseCreatedAtIso: string,
|
|
128
|
+
nowIso: string
|
|
129
|
+
): Promise<ValidationAuditEntry[]> => {
|
|
130
|
+
const rangeStart = toUtcDayStart(caseCreatedAtIso);
|
|
131
|
+
const rangeEnd = toUtcDayEnd(nowIso);
|
|
132
|
+
|
|
133
|
+
const mergedEntries = new Map<string, ValidationAuditEntry>();
|
|
134
|
+
let windowStart = new Date(rangeStart);
|
|
135
|
+
|
|
136
|
+
while (windowStart.getTime() <= rangeEnd.getTime()) {
|
|
137
|
+
const windowEndCandidate = addUtcDays(windowStart, AUDIT_FETCH_WINDOW_DAYS - 1);
|
|
138
|
+
const windowEnd = windowEndCandidate.getTime() > rangeEnd.getTime() ? new Date(rangeEnd) : windowEndCandidate;
|
|
139
|
+
|
|
140
|
+
const windowEntries = await auditService.getAuditEntriesForUser(user.uid, {
|
|
141
|
+
requestingUser: user,
|
|
142
|
+
caseNumber,
|
|
143
|
+
startDate: windowStart.toISOString(),
|
|
144
|
+
endDate: windowEnd.toISOString()
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
for (const entry of windowEntries) {
|
|
148
|
+
mergedEntries.set(getAuditEntryIdentity(entry), entry);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
windowStart = addUtcDays(windowEnd, 1);
|
|
152
|
+
windowStart = new Date(Date.UTC(
|
|
153
|
+
windowStart.getUTCFullYear(),
|
|
154
|
+
windowStart.getUTCMonth(),
|
|
155
|
+
windowStart.getUTCDate(),
|
|
156
|
+
0,
|
|
157
|
+
0,
|
|
158
|
+
0,
|
|
159
|
+
0
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return Array.from(mergedEntries.values());
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const extractErrorMessage = async (response: Response): Promise<string> => {
|
|
167
|
+
const contentType = response.headers.get('Content-Type') ?? '';
|
|
168
|
+
|
|
169
|
+
if (contentType.includes('application/json')) {
|
|
170
|
+
try {
|
|
171
|
+
const json = await response.json() as { error?: string; details?: string };
|
|
172
|
+
if (json.error) {
|
|
173
|
+
return json.details ? `${json.error}: ${json.details}` : json.error;
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// fall through to text fallback
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const text = await response.text();
|
|
182
|
+
return text.trim() || `HTTP ${response.status} ${response.statusText}`;
|
|
183
|
+
} catch {
|
|
184
|
+
return `HTTP ${response.status} ${response.statusText}`;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const buildPartFilename = (
|
|
189
|
+
caseNumber: string,
|
|
190
|
+
exportDate: Date,
|
|
191
|
+
partIndex: number,
|
|
192
|
+
totalParts: number
|
|
193
|
+
): string => {
|
|
194
|
+
const sanitizedCaseNumber = caseNumber.trim().replace(/[^a-zA-Z0-9_-]+/g, '-');
|
|
195
|
+
const dateStamp = formatDateStamp(exportDate);
|
|
196
|
+
|
|
197
|
+
if (totalParts <= 1) {
|
|
198
|
+
return `audit-trail-${sanitizedCaseNumber}-${dateStamp}.pdf`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return `audit-trail-${sanitizedCaseNumber}-${dateStamp}-part-${partIndex}-of-${totalParts}.pdf`;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const buildAuditTrailPayload = (
|
|
205
|
+
caseNumber: string,
|
|
206
|
+
entries: ValidationAuditEntry[],
|
|
207
|
+
exportRangeStart: string,
|
|
208
|
+
exportRangeEnd: string,
|
|
209
|
+
chunkIndex: number,
|
|
210
|
+
totalChunks: number,
|
|
211
|
+
totalEntries: number,
|
|
212
|
+
exportedAt: string
|
|
213
|
+
): AuditTrailPdfPayload => {
|
|
214
|
+
return {
|
|
215
|
+
reportMode: 'audit-trail',
|
|
216
|
+
caseNumber,
|
|
217
|
+
exportedAt,
|
|
218
|
+
exportRangeStart,
|
|
219
|
+
exportRangeEnd,
|
|
220
|
+
chunkIndex,
|
|
221
|
+
totalChunks,
|
|
222
|
+
totalEntries,
|
|
223
|
+
includeRawJsonAppendix: true,
|
|
224
|
+
entries
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const exportAuditPDF = async ({
|
|
229
|
+
user,
|
|
230
|
+
caseNumber,
|
|
231
|
+
userCompany,
|
|
232
|
+
userFirstName,
|
|
233
|
+
userLastName,
|
|
234
|
+
userBadgeId,
|
|
235
|
+
setIsExportingPDF,
|
|
236
|
+
setToastType,
|
|
237
|
+
setToastMessage,
|
|
238
|
+
setShowToast,
|
|
239
|
+
setToastDuration
|
|
240
|
+
}: ExportAuditPdfParams): Promise<void> => {
|
|
241
|
+
setIsExportingPDF(true);
|
|
242
|
+
setToastType('loading');
|
|
243
|
+
setToastMessage('Preparing full case audit trail PDF export...');
|
|
244
|
+
if (setToastDuration) {
|
|
245
|
+
setToastDuration(0);
|
|
246
|
+
}
|
|
247
|
+
setShowToast(true);
|
|
248
|
+
|
|
249
|
+
const exportStartTime = Date.now();
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
253
|
+
if (!accessCheck.allowed) {
|
|
254
|
+
throw new Error(accessCheck.reason || 'You do not have access to export this case audit trail.');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const caseData = await getCaseData(user, caseNumber);
|
|
258
|
+
const now = new Date();
|
|
259
|
+
const nowIso = now.toISOString();
|
|
260
|
+
const caseCreatedAtIso = normalizeIsoDate(caseData?.createdAt) || '1970-01-01T00:00:00.000Z';
|
|
261
|
+
const isBundledArchivedCase = Boolean(
|
|
262
|
+
caseData?.isReadOnly === true &&
|
|
263
|
+
caseData?.archived === true &&
|
|
264
|
+
caseData?.bundledAuditTrail?.source === 'archive-bundle'
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const allEntries = isBundledArchivedCase
|
|
268
|
+
? await auditService.getAuditEntriesForUser(user.uid, {
|
|
269
|
+
requestingUser: user,
|
|
270
|
+
caseNumber
|
|
271
|
+
})
|
|
272
|
+
: await fetchAllCaseEntriesForExport(user, caseNumber, caseCreatedAtIso, nowIso);
|
|
273
|
+
|
|
274
|
+
if (allEntries.length === 0) {
|
|
275
|
+
setToastType('warning');
|
|
276
|
+
setToastMessage(`No audit entries were found for case ${caseNumber}.`);
|
|
277
|
+
if (setToastDuration) {
|
|
278
|
+
setToastDuration(5000);
|
|
279
|
+
}
|
|
280
|
+
setShowToast(true);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const sortedEntries = [...allEntries].sort(
|
|
285
|
+
(left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()
|
|
286
|
+
);
|
|
287
|
+
const exportRangeStartIso = isBundledArchivedCase
|
|
288
|
+
? normalizeIsoDate(sortedEntries[0]?.timestamp) || caseCreatedAtIso
|
|
289
|
+
: caseCreatedAtIso;
|
|
290
|
+
const exportRangeEndIso = normalizeIsoDate(sortedEntries[sortedEntries.length - 1]?.timestamp) || nowIso;
|
|
291
|
+
|
|
292
|
+
const chunks = chunkEntries(sortedEntries);
|
|
293
|
+
const totalChunks = chunks.length;
|
|
294
|
+
const exportedAtIso = nowIso;
|
|
295
|
+
const currentDate = formatShortDate(now);
|
|
296
|
+
|
|
297
|
+
let successfulParts = 0;
|
|
298
|
+
const failedParts: number[] = [];
|
|
299
|
+
|
|
300
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
|
301
|
+
const partNumber = chunkIndex + 1;
|
|
302
|
+
const chunkEntriesForPart = chunks[chunkIndex];
|
|
303
|
+
const filename = buildPartFilename(caseNumber, now, partNumber, totalChunks);
|
|
304
|
+
const partStartTime = Date.now();
|
|
305
|
+
|
|
306
|
+
const pdfData = {
|
|
307
|
+
reportMode: 'audit-trail' as const,
|
|
308
|
+
currentDate,
|
|
309
|
+
caseNumber,
|
|
310
|
+
userCompany,
|
|
311
|
+
userFirstName,
|
|
312
|
+
userLastName,
|
|
313
|
+
userBadgeId,
|
|
314
|
+
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
315
|
+
auditTrailReport: buildAuditTrailPayload(
|
|
316
|
+
caseNumber,
|
|
317
|
+
chunkEntriesForPart,
|
|
318
|
+
exportRangeStartIso,
|
|
319
|
+
exportRangeEndIso,
|
|
320
|
+
partNumber,
|
|
321
|
+
totalChunks,
|
|
322
|
+
sortedEntries.length,
|
|
323
|
+
exportedAtIso
|
|
324
|
+
)
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
setToastType('loading');
|
|
328
|
+
setToastMessage(`Generating audit PDF part ${partNumber} of ${totalChunks}...`);
|
|
329
|
+
setShowToast(true);
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const response = await fetchPdfApi(user, '/', {
|
|
333
|
+
method: 'POST',
|
|
334
|
+
headers: {
|
|
335
|
+
'Content-Type': 'application/json'
|
|
336
|
+
},
|
|
337
|
+
body: JSON.stringify({ data: pdfData })
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (!response.ok) {
|
|
341
|
+
const errorMessage = await extractErrorMessage(response);
|
|
342
|
+
throw new Error(errorMessage);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const blob = await response.blob();
|
|
346
|
+
downloadPdfBlob(blob, filename);
|
|
347
|
+
successfulParts += 1;
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
await auditService.logPDFGeneration(
|
|
351
|
+
user,
|
|
352
|
+
filename,
|
|
353
|
+
caseNumber,
|
|
354
|
+
'success',
|
|
355
|
+
Date.now() - partStartTime,
|
|
356
|
+
blob.size,
|
|
357
|
+
[],
|
|
358
|
+
`audit-trail:${caseNumber}:part-${partNumber}`,
|
|
359
|
+
`audit-trail-${caseNumber}`
|
|
360
|
+
);
|
|
361
|
+
} catch (auditError) {
|
|
362
|
+
console.error('Failed to log audit PDF generation success:', auditError);
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
failedParts.push(partNumber);
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
await auditService.logPDFGeneration(
|
|
369
|
+
user,
|
|
370
|
+
filename,
|
|
371
|
+
caseNumber,
|
|
372
|
+
'failure',
|
|
373
|
+
Date.now() - partStartTime,
|
|
374
|
+
0,
|
|
375
|
+
[error instanceof Error ? error.message : 'Unknown PDF generation error'],
|
|
376
|
+
`audit-trail:${caseNumber}:part-${partNumber}`,
|
|
377
|
+
`audit-trail-${caseNumber}`
|
|
378
|
+
);
|
|
379
|
+
} catch (auditError) {
|
|
380
|
+
console.error('Failed to log audit PDF generation failure:', auditError);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (failedParts.length === 0) {
|
|
386
|
+
setToastType('success');
|
|
387
|
+
setToastMessage(
|
|
388
|
+
successfulParts > 1
|
|
389
|
+
? `Exported ${successfulParts} audit PDF parts for case ${caseNumber}.`
|
|
390
|
+
: `Exported audit PDF for case ${caseNumber}.`
|
|
391
|
+
);
|
|
392
|
+
if (setToastDuration) {
|
|
393
|
+
setToastDuration(6000);
|
|
394
|
+
}
|
|
395
|
+
setShowToast(true);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const failedPartLabel = failedParts.join(', ');
|
|
400
|
+
setToastType('warning');
|
|
401
|
+
setToastMessage(
|
|
402
|
+
successfulParts > 0
|
|
403
|
+
? `Export completed with issues. Successful parts: ${successfulParts}/${totalChunks}. Failed parts: ${failedPartLabel}.`
|
|
404
|
+
: `Audit PDF export failed. Failed parts: ${failedPartLabel}.`
|
|
405
|
+
);
|
|
406
|
+
if (setToastDuration) {
|
|
407
|
+
setToastDuration(9000);
|
|
408
|
+
}
|
|
409
|
+
setShowToast(true);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
const processingTime = Date.now() - exportStartTime;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
await auditService.logPDFGeneration(
|
|
415
|
+
user,
|
|
416
|
+
`audit-trail-${caseNumber}-failed-${Date.now()}.pdf`,
|
|
417
|
+
caseNumber,
|
|
418
|
+
'failure',
|
|
419
|
+
processingTime,
|
|
420
|
+
0,
|
|
421
|
+
[error instanceof Error ? error.message : 'Unknown audit PDF export error'],
|
|
422
|
+
`audit-trail:${caseNumber}`,
|
|
423
|
+
`audit-trail-${caseNumber}`
|
|
424
|
+
);
|
|
425
|
+
} catch (auditError) {
|
|
426
|
+
console.error('Failed to log audit PDF export error:', auditError);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
setToastType('error');
|
|
430
|
+
setToastMessage(error instanceof Error ? error.message : 'Failed to export case audit trail PDF.');
|
|
431
|
+
if (setToastDuration) {
|
|
432
|
+
setToastDuration(7000);
|
|
433
|
+
}
|
|
434
|
+
setShowToast(true);
|
|
435
|
+
} finally {
|
|
436
|
+
setIsExportingPDF(false);
|
|
437
|
+
}
|
|
438
|
+
};
|