@striae-org/striae 5.4.5 → 5.5.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/app/components/actions/export-audit-pdf.ts +439 -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/notes.module.css +7 -1
- package/package.json +6 -6
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/src/audit-trail-report.ts +231 -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 +14 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -0,0 +1,439 @@
|
|
|
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 = isBundledArchivedCase
|
|
291
|
+
? normalizeIsoDate(sortedEntries[sortedEntries.length - 1]?.timestamp) || nowIso
|
|
292
|
+
: nowIso;
|
|
293
|
+
|
|
294
|
+
const chunks = chunkEntries(sortedEntries);
|
|
295
|
+
const totalChunks = chunks.length;
|
|
296
|
+
const exportedAtIso = nowIso;
|
|
297
|
+
const currentDate = formatShortDate(now);
|
|
298
|
+
|
|
299
|
+
let successfulParts = 0;
|
|
300
|
+
const failedParts: number[] = [];
|
|
301
|
+
|
|
302
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
|
303
|
+
const partNumber = chunkIndex + 1;
|
|
304
|
+
const chunkEntriesForPart = chunks[chunkIndex];
|
|
305
|
+
const filename = buildPartFilename(caseNumber, now, partNumber, totalChunks);
|
|
306
|
+
const partStartTime = Date.now();
|
|
307
|
+
|
|
308
|
+
const pdfData = {
|
|
309
|
+
reportMode: 'audit-trail' as const,
|
|
310
|
+
currentDate,
|
|
311
|
+
caseNumber,
|
|
312
|
+
userCompany,
|
|
313
|
+
userFirstName,
|
|
314
|
+
userLastName,
|
|
315
|
+
userBadgeId,
|
|
316
|
+
auditTrailReport: buildAuditTrailPayload(
|
|
317
|
+
caseNumber,
|
|
318
|
+
chunkEntriesForPart,
|
|
319
|
+
exportRangeStartIso,
|
|
320
|
+
exportRangeEndIso,
|
|
321
|
+
partNumber,
|
|
322
|
+
totalChunks,
|
|
323
|
+
sortedEntries.length,
|
|
324
|
+
exportedAtIso
|
|
325
|
+
)
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
setToastType('loading');
|
|
329
|
+
setToastMessage(`Generating audit PDF part ${partNumber} of ${totalChunks}...`);
|
|
330
|
+
setShowToast(true);
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const response = await fetchPdfApi(user, '/', {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: {
|
|
336
|
+
'Content-Type': 'application/json'
|
|
337
|
+
},
|
|
338
|
+
body: JSON.stringify({ data: pdfData })
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (!response.ok) {
|
|
342
|
+
const errorMessage = await extractErrorMessage(response);
|
|
343
|
+
throw new Error(errorMessage);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const blob = await response.blob();
|
|
347
|
+
downloadPdfBlob(blob, filename);
|
|
348
|
+
successfulParts += 1;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
await auditService.logPDFGeneration(
|
|
352
|
+
user,
|
|
353
|
+
filename,
|
|
354
|
+
caseNumber,
|
|
355
|
+
'success',
|
|
356
|
+
Date.now() - partStartTime,
|
|
357
|
+
blob.size,
|
|
358
|
+
[],
|
|
359
|
+
`audit-trail:${caseNumber}:part-${partNumber}`,
|
|
360
|
+
`audit-trail-${caseNumber}`
|
|
361
|
+
);
|
|
362
|
+
} catch (auditError) {
|
|
363
|
+
console.error('Failed to log audit PDF generation success:', auditError);
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
failedParts.push(partNumber);
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
await auditService.logPDFGeneration(
|
|
370
|
+
user,
|
|
371
|
+
filename,
|
|
372
|
+
caseNumber,
|
|
373
|
+
'failure',
|
|
374
|
+
Date.now() - partStartTime,
|
|
375
|
+
0,
|
|
376
|
+
[error instanceof Error ? error.message : 'Unknown PDF generation error'],
|
|
377
|
+
`audit-trail:${caseNumber}:part-${partNumber}`,
|
|
378
|
+
`audit-trail-${caseNumber}`
|
|
379
|
+
);
|
|
380
|
+
} catch (auditError) {
|
|
381
|
+
console.error('Failed to log audit PDF generation failure:', auditError);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (failedParts.length === 0) {
|
|
387
|
+
setToastType('success');
|
|
388
|
+
setToastMessage(
|
|
389
|
+
successfulParts > 1
|
|
390
|
+
? `Exported ${successfulParts} audit PDF parts for case ${caseNumber}.`
|
|
391
|
+
: `Exported audit PDF for case ${caseNumber}.`
|
|
392
|
+
);
|
|
393
|
+
if (setToastDuration) {
|
|
394
|
+
setToastDuration(6000);
|
|
395
|
+
}
|
|
396
|
+
setShowToast(true);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const failedPartLabel = failedParts.join(', ');
|
|
401
|
+
setToastType('warning');
|
|
402
|
+
setToastMessage(
|
|
403
|
+
successfulParts > 0
|
|
404
|
+
? `Export completed with issues. Successful parts: ${successfulParts}/${totalChunks}. Failed parts: ${failedPartLabel}.`
|
|
405
|
+
: `Audit PDF export failed. Failed parts: ${failedPartLabel}.`
|
|
406
|
+
);
|
|
407
|
+
if (setToastDuration) {
|
|
408
|
+
setToastDuration(9000);
|
|
409
|
+
}
|
|
410
|
+
setShowToast(true);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
const processingTime = Date.now() - exportStartTime;
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
await auditService.logPDFGeneration(
|
|
416
|
+
user,
|
|
417
|
+
`audit-trail-${caseNumber}-failed-${Date.now()}.pdf`,
|
|
418
|
+
caseNumber,
|
|
419
|
+
'failure',
|
|
420
|
+
processingTime,
|
|
421
|
+
0,
|
|
422
|
+
[error instanceof Error ? error.message : 'Unknown audit PDF export error'],
|
|
423
|
+
`audit-trail:${caseNumber}`,
|
|
424
|
+
`audit-trail-${caseNumber}`
|
|
425
|
+
);
|
|
426
|
+
} catch (auditError) {
|
|
427
|
+
console.error('Failed to log audit PDF export error:', auditError);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
setToastType('error');
|
|
431
|
+
setToastMessage(error instanceof Error ? error.message : 'Failed to export case audit trail PDF.');
|
|
432
|
+
if (setToastDuration) {
|
|
433
|
+
setToastDuration(7000);
|
|
434
|
+
}
|
|
435
|
+
setShowToast(true);
|
|
436
|
+
} finally {
|
|
437
|
+
setIsExportingPDF(false);
|
|
438
|
+
}
|
|
439
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { useContext, useMemo } from 'react';
|
|
1
|
+
import { useCallback, useContext, useMemo, useState } from 'react';
|
|
2
2
|
import { AuthContext } from '~/contexts/auth.context';
|
|
3
3
|
import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
4
|
+
import { exportAuditPDF } from '~/components/actions/export-audit-pdf';
|
|
5
|
+
import { Toast, type ToastType } from '~/components/toast/toast';
|
|
4
6
|
import { AuditViewerHeader } from './viewer/audit-viewer-header';
|
|
5
7
|
import { AuditUserInfoCard } from './viewer/audit-user-info-card';
|
|
6
8
|
import { AuditActivitySummary } from './viewer/audit-activity-summary';
|
|
@@ -20,6 +22,12 @@ interface UserAuditViewerProps {
|
|
|
20
22
|
|
|
21
23
|
export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAuditViewerProps) => {
|
|
22
24
|
const { user } = useContext(AuthContext);
|
|
25
|
+
const [isExportingPdf, setIsExportingPdf] = useState(false);
|
|
26
|
+
const [showToast, setShowToast] = useState(false);
|
|
27
|
+
const [toastType, setToastType] = useState<ToastType>('success');
|
|
28
|
+
const [toastMessage, setToastMessage] = useState('');
|
|
29
|
+
const [toastDuration, setToastDuration] = useState(4000);
|
|
30
|
+
|
|
23
31
|
const {
|
|
24
32
|
filterAction,
|
|
25
33
|
setFilterAction,
|
|
@@ -79,107 +87,157 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
|
|
|
79
87
|
});
|
|
80
88
|
|
|
81
89
|
const userBadgeId = userData?.badgeId?.trim() || '';
|
|
90
|
+
const isCaseScopedViewer = Boolean(effectiveCaseNumber?.trim());
|
|
91
|
+
|
|
92
|
+
const handleExportPdf = useCallback(async () => {
|
|
93
|
+
if (!user || !effectiveCaseNumber || isExportingPdf) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const displayName = user.displayName?.trim() || '';
|
|
98
|
+
const [firstFromDisplayName, ...lastFromDisplayNameParts] = displayName.split(/\s+/).filter(Boolean);
|
|
99
|
+
|
|
100
|
+
await exportAuditPDF({
|
|
101
|
+
user,
|
|
102
|
+
caseNumber: effectiveCaseNumber,
|
|
103
|
+
userCompany: userData?.company,
|
|
104
|
+
userFirstName: firstFromDisplayName || userData?.firstName || '',
|
|
105
|
+
userLastName: lastFromDisplayNameParts.join(' ') || userData?.lastName || '',
|
|
106
|
+
userBadgeId,
|
|
107
|
+
setIsExportingPDF: setIsExportingPdf,
|
|
108
|
+
setToastType,
|
|
109
|
+
setToastMessage,
|
|
110
|
+
setShowToast,
|
|
111
|
+
setToastDuration
|
|
112
|
+
});
|
|
113
|
+
}, [
|
|
114
|
+
effectiveCaseNumber,
|
|
115
|
+
isExportingPdf,
|
|
116
|
+
user,
|
|
117
|
+
userBadgeId,
|
|
118
|
+
userData?.company,
|
|
119
|
+
userData?.firstName,
|
|
120
|
+
userData?.lastName
|
|
121
|
+
]);
|
|
82
122
|
|
|
83
123
|
if (!isOpen) return null;
|
|
84
124
|
|
|
85
125
|
return (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
</
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
126
|
+
<>
|
|
127
|
+
<Toast
|
|
128
|
+
message={toastMessage}
|
|
129
|
+
type={toastType}
|
|
130
|
+
isVisible={showToast}
|
|
131
|
+
onClose={() => setShowToast(false)}
|
|
132
|
+
duration={toastDuration}
|
|
133
|
+
/>
|
|
134
|
+
<div
|
|
135
|
+
className={styles.overlay}
|
|
136
|
+
aria-label="Close audit trail dialog"
|
|
137
|
+
{...overlayProps}
|
|
138
|
+
>
|
|
139
|
+
<div className={styles.modal}>
|
|
140
|
+
<AuditViewerHeader
|
|
141
|
+
title={title || (effectiveCaseNumber ? `Audit Trail - Case ${effectiveCaseNumber}` : 'My Audit Trail')}
|
|
142
|
+
onClose={requestClose}
|
|
143
|
+
onExportPdf={isCaseScopedViewer ? () => void handleExportPdf() : undefined}
|
|
144
|
+
canExportPdf={isCaseScopedViewer && auditEntries.length > 0 && !loading && !error}
|
|
145
|
+
isExportingPdf={isExportingPdf}
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
<div className={styles.content}>
|
|
149
|
+
{loading && (
|
|
150
|
+
<div className={styles.loading}>
|
|
151
|
+
<div className={styles.spinner}></div>
|
|
152
|
+
<p>Loading your audit trail...this may take a while for longer time ranges</p>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{error && (
|
|
157
|
+
<div className={styles.error}>
|
|
158
|
+
<p>Error: {error}</p>
|
|
159
|
+
<button onClick={loadAuditData} className={styles.retryButton}>
|
|
160
|
+
Retry
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{!loading && !error && (
|
|
166
|
+
<>
|
|
167
|
+
{isArchivedReadOnlyCase && (
|
|
168
|
+
<div className={bundledAuditWarning ? styles.archivedWarning : styles.archivedNotice}>
|
|
169
|
+
<p>
|
|
170
|
+
{bundledAuditWarning || 'Viewing bundled audit trail data from this imported archived case package.'}
|
|
171
|
+
</p>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{/* User Information Section */}
|
|
176
|
+
{user && (
|
|
177
|
+
<AuditUserInfoCard user={user} userData={userData} userBadgeId={userBadgeId} />
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{/* Summary Section */}
|
|
181
|
+
<AuditActivitySummary
|
|
182
|
+
caseNumber={caseNumber}
|
|
183
|
+
filterCaseNumber={filterCaseNumber}
|
|
184
|
+
dateRangeDisplay={dateRangeDisplay}
|
|
185
|
+
summary={auditSummary}
|
|
186
|
+
/>
|
|
187
|
+
|
|
188
|
+
{isCaseScopedViewer && (
|
|
189
|
+
<div className={styles.exportScopeNote}>
|
|
190
|
+
Export PDF always includes full case history from case creation through now, regardless of current filters.
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{/* Filters */}
|
|
195
|
+
<AuditFiltersPanel
|
|
196
|
+
dateRange={dateRange}
|
|
197
|
+
customStartDate={customStartDate}
|
|
198
|
+
customEndDate={customEndDate}
|
|
199
|
+
customStartDateInput={customStartDateInput}
|
|
200
|
+
customEndDateInput={customEndDateInput}
|
|
201
|
+
caseNumber={caseNumber}
|
|
202
|
+
filterCaseNumber={filterCaseNumber}
|
|
203
|
+
caseNumberInput={caseNumberInput}
|
|
204
|
+
filterBadgeId={filterBadgeId}
|
|
205
|
+
badgeIdInput={badgeIdInput}
|
|
206
|
+
filterAction={filterAction}
|
|
207
|
+
filterResult={filterResult}
|
|
208
|
+
onDateRangeChange={handleDateRangeChange}
|
|
209
|
+
onCustomStartDateInputChange={setCustomStartDateInput}
|
|
210
|
+
onCustomEndDateInputChange={setCustomEndDateInput}
|
|
211
|
+
onApplyCustomDateRange={handleApplyCustomDateRange}
|
|
212
|
+
onClearCustomDateRange={handleClearCustomDateRange}
|
|
213
|
+
onCaseNumberInputChange={setCaseNumberInput}
|
|
214
|
+
onApplyCaseFilter={handleApplyCaseFilter}
|
|
215
|
+
onClearCaseFilter={handleClearCaseFilter}
|
|
216
|
+
onBadgeIdInputChange={setBadgeIdInput}
|
|
217
|
+
onApplyBadgeFilter={handleApplyBadgeFilter}
|
|
218
|
+
onClearBadgeFilter={handleClearBadgeFilter}
|
|
219
|
+
onFilterActionChange={setFilterAction}
|
|
220
|
+
onFilterResultChange={setFilterResult}
|
|
221
|
+
/>
|
|
222
|
+
|
|
223
|
+
{/* Entries List */}
|
|
224
|
+
<AuditEntriesList entries={filteredEntries} />
|
|
225
|
+
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{auditEntries.length === 0 && !loading && !error && (
|
|
230
|
+
<div className={styles.noData}>
|
|
231
|
+
<p>
|
|
232
|
+
{isArchivedReadOnlyCase
|
|
233
|
+
? 'No bundled audit trail entries are available for this imported archived case.'
|
|
234
|
+
: 'No audit trail available. Your activities will appear here as you use Striae.'}
|
|
235
|
+
</p>
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
181
239
|
</div>
|
|
182
240
|
</div>
|
|
183
|
-
|
|
241
|
+
</>
|
|
184
242
|
);
|
|
185
243
|
};
|
|
@@ -72,6 +72,12 @@
|
|
|
72
72
|
transform: translateY(0);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
.exportButton:disabled {
|
|
76
|
+
opacity: 0.65;
|
|
77
|
+
cursor: not-allowed;
|
|
78
|
+
box-shadow: none;
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
.title {
|
|
76
82
|
margin: 0;
|
|
77
83
|
color: var(--textTitle);
|
|
@@ -178,6 +184,17 @@
|
|
|
178
184
|
border: 1px solid color-mix(in lab, var(--textLight) 20%, transparent);
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
.exportScopeNote {
|
|
188
|
+
margin: -12px 0 16px;
|
|
189
|
+
padding: 10px 12px;
|
|
190
|
+
border-radius: 6px;
|
|
191
|
+
border: 1px solid color-mix(in lab, var(--primary) 30%, transparent);
|
|
192
|
+
background: color-mix(in lab, var(--primary) 10%, transparent);
|
|
193
|
+
color: var(--textBody);
|
|
194
|
+
font-size: 0.85rem;
|
|
195
|
+
line-height: 1.45;
|
|
196
|
+
}
|
|
197
|
+
|
|
181
198
|
/* Common heading styles */
|
|
182
199
|
.summary h3,
|
|
183
200
|
.entriesList h3 {
|
|
@@ -3,16 +3,32 @@ import styles from '../user-audit.module.css';
|
|
|
3
3
|
interface AuditViewerHeaderProps {
|
|
4
4
|
title: string;
|
|
5
5
|
onClose: () => void;
|
|
6
|
+
onExportPdf?: () => void;
|
|
7
|
+
canExportPdf?: boolean;
|
|
8
|
+
isExportingPdf?: boolean;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
export const AuditViewerHeader = ({
|
|
9
12
|
title,
|
|
10
13
|
onClose,
|
|
14
|
+
onExportPdf,
|
|
15
|
+
canExportPdf = false,
|
|
16
|
+
isExportingPdf = false,
|
|
11
17
|
}: AuditViewerHeaderProps) => {
|
|
12
18
|
return (
|
|
13
19
|
<div className={styles.header}>
|
|
14
20
|
<h2 className={styles.title}>{title}</h2>
|
|
15
21
|
<div className={styles.headerActions}>
|
|
22
|
+
{onExportPdf && (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
className={styles.exportButton}
|
|
26
|
+
onClick={onExportPdf}
|
|
27
|
+
disabled={!canExportPdf || isExportingPdf}
|
|
28
|
+
>
|
|
29
|
+
{isExportingPdf ? 'Exporting PDF...' : 'Export PDF'}
|
|
30
|
+
</button>
|
|
31
|
+
)}
|
|
16
32
|
<button className={styles.closeButton} onClick={onClose}>
|
|
17
33
|
×
|
|
18
34
|
</button>
|
|
@@ -730,7 +730,13 @@ textarea:focus {
|
|
|
730
730
|
position: sticky;
|
|
731
731
|
bottom: 0;
|
|
732
732
|
padding: 0.75rem 0 0.25rem;
|
|
733
|
-
background: linear-gradient(
|
|
733
|
+
background: linear-gradient(
|
|
734
|
+
to bottom,
|
|
735
|
+
rgba(255, 255, 255, 0),
|
|
736
|
+
white 28%,
|
|
737
|
+
white 72%,
|
|
738
|
+
rgba(255, 255, 255, 0)
|
|
739
|
+
);
|
|
734
740
|
}
|
|
735
741
|
|
|
736
742
|
.saveButton:hover {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@striae-org/striae",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -115,8 +115,8 @@
|
|
|
115
115
|
"@types/qrcode": "^1.5.6",
|
|
116
116
|
"@types/react": "^19.2.14",
|
|
117
117
|
"@types/react-dom": "^19.2.3",
|
|
118
|
-
"@typescript-eslint/eslint-plugin": "^8.58.
|
|
119
|
-
"@typescript-eslint/parser": "^8.58.
|
|
118
|
+
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
|
119
|
+
"@typescript-eslint/parser": "^8.58.1",
|
|
120
120
|
"eslint": "^9.39.4",
|
|
121
121
|
"eslint-import-resolver-typescript": "^4.4.4",
|
|
122
122
|
"eslint-plugin-import": "^2.32.0",
|
|
@@ -126,9 +126,9 @@
|
|
|
126
126
|
"firebase-admin": "^13.7.0",
|
|
127
127
|
"modern-normalize": "^3.0.1",
|
|
128
128
|
"typescript": "^5.9.3",
|
|
129
|
-
"vite": "^
|
|
129
|
+
"vite": "^7.3.2",
|
|
130
130
|
"vite-tsconfig-paths": "^6.1.1",
|
|
131
|
-
"wrangler": "^4.
|
|
131
|
+
"wrangler": "^4.81.0"
|
|
132
132
|
},
|
|
133
133
|
"overrides": {
|
|
134
134
|
"@tootallnate/once": "3.0.1",
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"undici": "7.24.1"
|
|
137
137
|
},
|
|
138
138
|
"engines": {
|
|
139
|
-
"node": ">=20.
|
|
139
|
+
"node": ">=20.19.0"
|
|
140
140
|
},
|
|
141
141
|
"packageManager": "npm@11.11.0"
|
|
142
142
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { AuditTrailReportPayload, PDFGenerationData, ReportPdfOptions } from './report-types';
|
|
2
|
+
import { buildRepeatedChromePdfOptions, escapeHtml } from './report-layout';
|
|
3
|
+
|
|
4
|
+
const safeText = (value: unknown): string => escapeHtml(String(value ?? ''));
|
|
5
|
+
|
|
6
|
+
const formatTimestamp = (timestamp: string): string => {
|
|
7
|
+
const parsed = new Date(timestamp);
|
|
8
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
9
|
+
return timestamp;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return parsed.toISOString();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const renderEntryDetailsSummary = (entry: Record<string, unknown>): string => {
|
|
16
|
+
const details = (entry.details ?? {}) as Record<string, unknown>;
|
|
17
|
+
const caseNumber = typeof details.caseNumber === 'string' ? details.caseNumber : '';
|
|
18
|
+
const workflowPhase = typeof details.workflowPhase === 'string' ? details.workflowPhase : '';
|
|
19
|
+
const fileName = typeof details.fileName === 'string' ? details.fileName : '';
|
|
20
|
+
|
|
21
|
+
const summaryRows = [
|
|
22
|
+
caseNumber ? `<div><strong>Case:</strong> ${safeText(caseNumber)}</div>` : '',
|
|
23
|
+
workflowPhase ? `<div><strong>Phase:</strong> ${safeText(workflowPhase)}</div>` : '',
|
|
24
|
+
fileName ? `<div><strong>File:</strong> ${safeText(fileName)}</div>` : ''
|
|
25
|
+
].filter(Boolean);
|
|
26
|
+
|
|
27
|
+
if (summaryRows.length === 0) {
|
|
28
|
+
return '<div class="entry-meta-muted">No structured summary fields available.</div>';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return summaryRows.join('');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const renderRawJsonAppendix = (entry: unknown): string => {
|
|
35
|
+
if (entry === null || entry === undefined) {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let rawJson: string;
|
|
40
|
+
try {
|
|
41
|
+
rawJson = JSON.stringify(entry, null, 2) ?? '';
|
|
42
|
+
} catch {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!rawJson) {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return `
|
|
51
|
+
<div class="entry-raw-json">
|
|
52
|
+
<div class="entry-raw-label">Raw JSON Entry</div>
|
|
53
|
+
<pre>${escapeHtml(rawJson)}</pre>
|
|
54
|
+
</div>
|
|
55
|
+
`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const isAuditTrailReportMode = (data: PDFGenerationData): boolean =>
|
|
59
|
+
data.reportMode === 'audit-trail';
|
|
60
|
+
|
|
61
|
+
export const getAuditTrailPayload = (data: PDFGenerationData): AuditTrailReportPayload => {
|
|
62
|
+
const payload = data.auditTrailReport;
|
|
63
|
+
|
|
64
|
+
if (!payload) {
|
|
65
|
+
throw new Error('Audit trail report payload is required when reportMode is audit-trail');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return payload;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const renderAuditTrailReport = (data: PDFGenerationData): string => {
|
|
72
|
+
const payload = getAuditTrailPayload(data);
|
|
73
|
+
const entries = payload.entries || [];
|
|
74
|
+
|
|
75
|
+
const entrySections = entries.map((entry, index) => {
|
|
76
|
+
const entryRecord = entry as Record<string, unknown>;
|
|
77
|
+
const timestamp = typeof entryRecord.timestamp === 'string' ? entryRecord.timestamp : 'unknown';
|
|
78
|
+
const action = typeof entryRecord.action === 'string' ? entryRecord.action : 'unknown';
|
|
79
|
+
const result = typeof entryRecord.result === 'string' ? entryRecord.result : 'unknown';
|
|
80
|
+
const userEmail = typeof entryRecord.userEmail === 'string' ? entryRecord.userEmail : 'unknown';
|
|
81
|
+
const userId = typeof entryRecord.userId === 'string' ? entryRecord.userId : 'unknown';
|
|
82
|
+
|
|
83
|
+
return `
|
|
84
|
+
<section class="entry-section">
|
|
85
|
+
<h3 class="entry-title">Entry ${index + 1} of ${entries.length}</h3>
|
|
86
|
+
<div class="entry-core-grid">
|
|
87
|
+
<div><strong>Timestamp:</strong> ${safeText(formatTimestamp(timestamp))}</div>
|
|
88
|
+
<div><strong>Action:</strong> ${safeText(action)}</div>
|
|
89
|
+
<div><strong>Result:</strong> ${safeText(result)}</div>
|
|
90
|
+
<div><strong>User Email:</strong> ${safeText(userEmail)}</div>
|
|
91
|
+
<div><strong>User ID:</strong> ${safeText(userId)}</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="entry-meta">
|
|
94
|
+
${renderEntryDetailsSummary(entryRecord)}
|
|
95
|
+
</div>
|
|
96
|
+
${renderRawJsonAppendix(entry)}
|
|
97
|
+
</section>
|
|
98
|
+
`;
|
|
99
|
+
}).join('');
|
|
100
|
+
|
|
101
|
+
return `
|
|
102
|
+
<!DOCTYPE html>
|
|
103
|
+
<html lang="en">
|
|
104
|
+
<head>
|
|
105
|
+
<meta charset="utf-8" />
|
|
106
|
+
<style>
|
|
107
|
+
html, body {
|
|
108
|
+
margin: 0;
|
|
109
|
+
padding: 0;
|
|
110
|
+
width: 100%;
|
|
111
|
+
font-family: Arial, sans-serif;
|
|
112
|
+
color: #1f2933;
|
|
113
|
+
background: #ffffff;
|
|
114
|
+
}
|
|
115
|
+
body {
|
|
116
|
+
box-sizing: border-box;
|
|
117
|
+
}
|
|
118
|
+
.report-body {
|
|
119
|
+
width: 100%;
|
|
120
|
+
box-sizing: border-box;
|
|
121
|
+
}
|
|
122
|
+
.summary {
|
|
123
|
+
border: 1px solid #d0d7de;
|
|
124
|
+
border-radius: 8px;
|
|
125
|
+
padding: 16px;
|
|
126
|
+
margin-bottom: 16px;
|
|
127
|
+
page-break-inside: avoid;
|
|
128
|
+
}
|
|
129
|
+
.summary h1 {
|
|
130
|
+
margin: 0 0 8px;
|
|
131
|
+
font-size: 22px;
|
|
132
|
+
}
|
|
133
|
+
.summary-grid {
|
|
134
|
+
display: grid;
|
|
135
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
136
|
+
gap: 6px 12px;
|
|
137
|
+
font-size: 12px;
|
|
138
|
+
}
|
|
139
|
+
.entry-section {
|
|
140
|
+
border: 1px solid #d0d7de;
|
|
141
|
+
border-radius: 8px;
|
|
142
|
+
padding: 12px;
|
|
143
|
+
margin-bottom: 12px;
|
|
144
|
+
page-break-inside: avoid;
|
|
145
|
+
}
|
|
146
|
+
.entry-title {
|
|
147
|
+
margin: 0 0 8px;
|
|
148
|
+
font-size: 14px;
|
|
149
|
+
}
|
|
150
|
+
.entry-core-grid {
|
|
151
|
+
display: grid;
|
|
152
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
153
|
+
gap: 6px 12px;
|
|
154
|
+
font-size: 11px;
|
|
155
|
+
}
|
|
156
|
+
.entry-meta {
|
|
157
|
+
margin-top: 10px;
|
|
158
|
+
font-size: 11px;
|
|
159
|
+
color: #334155;
|
|
160
|
+
}
|
|
161
|
+
.entry-meta-muted {
|
|
162
|
+
color: #64748b;
|
|
163
|
+
font-style: italic;
|
|
164
|
+
}
|
|
165
|
+
.entry-raw-json {
|
|
166
|
+
margin-top: 12px;
|
|
167
|
+
border-top: 1px dashed #c5ced8;
|
|
168
|
+
padding-top: 10px;
|
|
169
|
+
}
|
|
170
|
+
.entry-raw-label {
|
|
171
|
+
font-size: 10px;
|
|
172
|
+
letter-spacing: 0.05em;
|
|
173
|
+
text-transform: uppercase;
|
|
174
|
+
color: #475569;
|
|
175
|
+
margin-bottom: 6px;
|
|
176
|
+
font-weight: 700;
|
|
177
|
+
}
|
|
178
|
+
.entry-raw-json pre {
|
|
179
|
+
margin: 0;
|
|
180
|
+
padding: 10px;
|
|
181
|
+
border: 1px solid #d0d7de;
|
|
182
|
+
border-radius: 6px;
|
|
183
|
+
background: #f8fafc;
|
|
184
|
+
color: #0f172a;
|
|
185
|
+
font-size: 10px;
|
|
186
|
+
line-height: 1.5;
|
|
187
|
+
white-space: pre-wrap;
|
|
188
|
+
word-break: break-word;
|
|
189
|
+
}
|
|
190
|
+
</style>
|
|
191
|
+
</head>
|
|
192
|
+
<body>
|
|
193
|
+
<div class="report-body">
|
|
194
|
+
<section class="summary">
|
|
195
|
+
<h1>Case Audit Trail Report</h1>
|
|
196
|
+
<div class="summary-grid">
|
|
197
|
+
<div><strong>Case Number:</strong> ${safeText(payload.caseNumber)}</div>
|
|
198
|
+
<div><strong>Exported At:</strong> ${safeText(formatTimestamp(payload.exportedAt))}</div>
|
|
199
|
+
<div><strong>Range Start:</strong> ${safeText(formatTimestamp(payload.exportRangeStart))}</div>
|
|
200
|
+
<div><strong>Range End:</strong> ${safeText(formatTimestamp(payload.exportRangeEnd))}</div>
|
|
201
|
+
<div><strong>Total Entries (All Parts):</strong> ${safeText(payload.totalEntries)}</div>
|
|
202
|
+
<div><strong>This Part:</strong> ${safeText(payload.chunkIndex)} of ${safeText(payload.totalChunks)}</div>
|
|
203
|
+
<div><strong>Entries in Part:</strong> ${safeText(entries.length)}</div>
|
|
204
|
+
<div><strong>Raw JSON Appendix:</strong> Included when available</div>
|
|
205
|
+
</div>
|
|
206
|
+
</section>
|
|
207
|
+
${entrySections}
|
|
208
|
+
</div>
|
|
209
|
+
</body>
|
|
210
|
+
</html>
|
|
211
|
+
`;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const getAuditTrailPdfOptions = (data: PDFGenerationData): Partial<ReportPdfOptions> => {
|
|
215
|
+
const payload = getAuditTrailPayload(data);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
format: 'letter',
|
|
219
|
+
...buildRepeatedChromePdfOptions({
|
|
220
|
+
headerLeft: data.currentDate,
|
|
221
|
+
headerCenter: 'Case Audit Trail Report',
|
|
222
|
+
headerRight: `Case ${payload.caseNumber}`,
|
|
223
|
+
headerDetailLeft: `Entries ${payload.totalEntries}`,
|
|
224
|
+
headerDetailRight: `Part ${payload.chunkIndex}/${payload.totalChunks}`,
|
|
225
|
+
footerLeft: 'Striae Audit Export',
|
|
226
|
+
footerCenter: payload.exportedAt,
|
|
227
|
+
footerRight: `Case ${payload.caseNumber}`,
|
|
228
|
+
includePageNumbers: true
|
|
229
|
+
})
|
|
230
|
+
};
|
|
231
|
+
};
|
|
@@ -480,17 +480,19 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
|
|
|
480
480
|
`;
|
|
481
481
|
};
|
|
482
482
|
|
|
483
|
-
export const getPdfOptions: ReportPdfOptionsBuilder = (data: PDFGenerationData) =>
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
483
|
+
export const getPdfOptions: ReportPdfOptionsBuilder = (data: PDFGenerationData) => {
|
|
484
|
+
return buildRepeatedChromePdfOptions({
|
|
485
|
+
headerLeft: data.currentDate,
|
|
486
|
+
headerRight: data.caseNumber,
|
|
487
|
+
headerDetailLeft: [data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')
|
|
488
|
+
? `Left Case / Item: ${[data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')}`
|
|
489
|
+
: undefined,
|
|
490
|
+
headerDetailRight: [data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')
|
|
491
|
+
? `Right Case / Item: ${[data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')}`
|
|
492
|
+
: undefined,
|
|
493
|
+
footerLeft: 'Notes formatted by Striae',
|
|
494
|
+
footerCenter: data.userCompany,
|
|
495
|
+
footerRight: data.notesUpdatedFormatted ? `Notes updated ${data.notesUpdatedFormatted}` : undefined,
|
|
496
|
+
footerLeftImageSrc: ICON_256,
|
|
497
|
+
});
|
|
498
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PDFGenerationData, PDFGenerationRequest, ReportModule, ReportPdfOptions } from './report-types';
|
|
2
|
+
import { getAuditTrailPdfOptions, isAuditTrailReportMode, renderAuditTrailReport } from './audit-trail-report';
|
|
2
3
|
|
|
3
4
|
interface Env {
|
|
4
5
|
PDF_WORKER_AUTH: string;
|
|
@@ -112,6 +113,13 @@ function resolveReportRequest(payload: unknown): PDFGenerationRequest {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
async function renderReport(reportFormat: string, data: PDFGenerationData): Promise<{ html: string; pdfOptions: ReportPdfOptions }> {
|
|
116
|
+
if (isAuditTrailReportMode(data)) {
|
|
117
|
+
return {
|
|
118
|
+
html: renderAuditTrailReport(data),
|
|
119
|
+
pdfOptions: resolvePdfOptions(getAuditTrailPdfOptions(data)),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
115
123
|
const loader = reportModuleLoaders[reportFormat];
|
|
116
124
|
|
|
117
125
|
if (!loader) {
|
|
@@ -58,6 +58,20 @@ export interface PDFGenerationData {
|
|
|
58
58
|
userFirstName?: string;
|
|
59
59
|
userLastName?: string;
|
|
60
60
|
userBadgeId?: string;
|
|
61
|
+
reportMode?: 'audit-trail';
|
|
62
|
+
auditTrailReport?: AuditTrailReportPayload;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AuditTrailReportPayload {
|
|
66
|
+
caseNumber: string;
|
|
67
|
+
exportedAt: string;
|
|
68
|
+
exportRangeStart: string;
|
|
69
|
+
exportRangeEnd: string;
|
|
70
|
+
chunkIndex: number;
|
|
71
|
+
totalChunks: number;
|
|
72
|
+
totalEntries: number;
|
|
73
|
+
includeRawJsonAppendix: boolean;
|
|
74
|
+
entries: unknown[];
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
export interface PDFGenerationRequest {
|
package/wrangler.toml.example
CHANGED