@striae-org/striae 5.5.0 → 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 +2 -3
- 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 +77 -0
- package/app/components/user/user.module.css +1 -1
- package/package.json +4 -4
- 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 +28 -6
- package/workers/pdf-worker/src/report-types.ts +1 -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
|
@@ -287,9 +287,7 @@ export const exportAuditPDF = async ({
|
|
|
287
287
|
const exportRangeStartIso = isBundledArchivedCase
|
|
288
288
|
? normalizeIsoDate(sortedEntries[0]?.timestamp) || caseCreatedAtIso
|
|
289
289
|
: caseCreatedAtIso;
|
|
290
|
-
const exportRangeEndIso =
|
|
291
|
-
? normalizeIsoDate(sortedEntries[sortedEntries.length - 1]?.timestamp) || nowIso
|
|
292
|
-
: nowIso;
|
|
290
|
+
const exportRangeEndIso = normalizeIsoDate(sortedEntries[sortedEntries.length - 1]?.timestamp) || nowIso;
|
|
293
291
|
|
|
294
292
|
const chunks = chunkEntries(sortedEntries);
|
|
295
293
|
const totalChunks = chunks.length;
|
|
@@ -313,6 +311,7 @@ export const exportAuditPDF = async ({
|
|
|
313
311
|
userFirstName,
|
|
314
312
|
userLastName,
|
|
315
313
|
userBadgeId,
|
|
314
|
+
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
316
315
|
auditTrailReport: buildAuditTrailPayload(
|
|
317
316
|
caseNumber,
|
|
318
317
|
chunkEntriesForPart,
|
|
@@ -118,10 +118,10 @@ const ClassDetailsModalContent = ({
|
|
|
118
118
|
/>
|
|
119
119
|
)}
|
|
120
120
|
</div>
|
|
121
|
-
<div className={styles.modalButtons}>
|
|
121
|
+
<div className={`${styles.modalButtons} ${styles.classDetailsModalButtons}`}>
|
|
122
122
|
<button
|
|
123
123
|
onClick={handleSave}
|
|
124
|
-
className={styles.saveButton}
|
|
124
|
+
className={`${styles.saveButton} ${styles.classDetailsModalAction}`}
|
|
125
125
|
disabled={isSaving || isReadOnly}
|
|
126
126
|
aria-busy={isSaving}
|
|
127
127
|
>
|
|
@@ -129,7 +129,7 @@ const ClassDetailsModalContent = ({
|
|
|
129
129
|
</button>
|
|
130
130
|
<button
|
|
131
131
|
onClick={requestClose}
|
|
132
|
-
className={styles.cancelButton}
|
|
132
|
+
className={`${styles.cancelButton} ${styles.classDetailsModalAction}`}
|
|
133
133
|
disabled={isSaving}
|
|
134
134
|
>
|
|
135
135
|
Cancel
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
1
|
+
import { useState, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
2
2
|
import type { User } from 'firebase/auth';
|
|
3
3
|
import { ColorSelector } from '~/components/colors/colors';
|
|
4
4
|
import { AddlNotesModal } from './addl-notes-modal';
|
|
@@ -18,13 +18,71 @@ interface NotesEditorFormProps {
|
|
|
18
18
|
originalFileName?: string;
|
|
19
19
|
isUploading?: boolean;
|
|
20
20
|
showNotification?: (message: string, type: 'success' | 'error' | 'warning') => void;
|
|
21
|
+
onDirtyChange?: (isDirty: boolean) => void;
|
|
22
|
+
onRegisterSaveHandler?: (saveHandler: (() => Promise<boolean>) | null) => void;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
type SupportLevel = 'ID' | 'Exclusion' | 'Inconclusive';
|
|
24
26
|
type ClassType = 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
|
|
25
27
|
type IndexType = 'number' | 'color';
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
interface NotesFormSnapshot {
|
|
30
|
+
leftCase: string;
|
|
31
|
+
rightCase: string;
|
|
32
|
+
leftItem: string;
|
|
33
|
+
rightItem: string;
|
|
34
|
+
caseFontColor: string;
|
|
35
|
+
classType: ClassType | '';
|
|
36
|
+
customClass: string;
|
|
37
|
+
classNote: string;
|
|
38
|
+
hasSubclass: boolean;
|
|
39
|
+
bulletData: BulletAnnotationData | undefined;
|
|
40
|
+
cartridgeCaseData: CartridgeCaseAnnotationData | undefined;
|
|
41
|
+
shotshellData: ShotshellAnnotationData | undefined;
|
|
42
|
+
indexType: IndexType;
|
|
43
|
+
indexNumber: string;
|
|
44
|
+
indexColor: string;
|
|
45
|
+
supportLevel: SupportLevel | '';
|
|
46
|
+
includeConfirmation: boolean;
|
|
47
|
+
additionalNotes: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hasMeaningfulValue = (value: unknown): boolean => {
|
|
51
|
+
if (value === undefined || value === null) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.some(hasMeaningfulValue);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof value === 'object') {
|
|
60
|
+
return Object.values(value as Record<string, unknown>).some(hasMeaningfulValue);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const normalizeNestedAnnotationData = <T extends object>(data: T | undefined): T | undefined => {
|
|
67
|
+
if (data === undefined || data === null) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return hasMeaningfulValue(data) ? data : undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const normalizeNotesSnapshot = (snapshot: NotesFormSnapshot): NotesFormSnapshot => ({
|
|
75
|
+
...snapshot,
|
|
76
|
+
bulletData: normalizeNestedAnnotationData(snapshot.bulletData),
|
|
77
|
+
cartridgeCaseData: normalizeNestedAnnotationData(snapshot.cartridgeCaseData),
|
|
78
|
+
shotshellData: normalizeNestedAnnotationData(snapshot.shotshellData),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const serializeNotesSnapshot = (snapshot: NotesFormSnapshot): string => JSON.stringify(normalizeNotesSnapshot(snapshot));
|
|
82
|
+
const DIRTY_CHECK_DEBOUNCE_MS = 180;
|
|
83
|
+
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
84
|
+
|
|
85
|
+
export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefresh, originalFileName, isUploading = false, showNotification: externalShowNotification, onDirtyChange, onRegisterSaveHandler }: NotesEditorFormProps) => {
|
|
28
86
|
// Loading/Saving Notes States
|
|
29
87
|
const [isLoading, setIsLoading] = useState(false);
|
|
30
88
|
const [loadError, setLoadError] = useState<string>();
|
|
@@ -64,13 +122,72 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
64
122
|
const [isClassOpen, setIsClassOpen] = useState(true);
|
|
65
123
|
const [isIndexOpen, setIsIndexOpen] = useState(true);
|
|
66
124
|
const [isSupportOpen, setIsSupportOpen] = useState(true);
|
|
125
|
+
const [savedSnapshot, setSavedSnapshot] = useState<string>('');
|
|
126
|
+
const [hasLoadedSnapshot, setHasLoadedSnapshot] = useState(false);
|
|
127
|
+
const [isDirty, setIsDirty] = useState(false);
|
|
67
128
|
const areInputsDisabled = isUploading || isConfirmedImage;
|
|
68
129
|
|
|
69
|
-
const notificationHandler = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
|
130
|
+
const notificationHandler = useCallback((message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
|
70
131
|
if (externalShowNotification) {
|
|
71
132
|
externalShowNotification(message, type);
|
|
72
133
|
}
|
|
73
|
-
};
|
|
134
|
+
}, [externalShowNotification]);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!hasLoadedSnapshot) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const timeoutId = window.setTimeout(() => {
|
|
142
|
+
const nextSnapshot = serializeNotesSnapshot({
|
|
143
|
+
leftCase,
|
|
144
|
+
rightCase,
|
|
145
|
+
leftItem,
|
|
146
|
+
rightItem,
|
|
147
|
+
caseFontColor,
|
|
148
|
+
classType,
|
|
149
|
+
customClass,
|
|
150
|
+
classNote,
|
|
151
|
+
hasSubclass,
|
|
152
|
+
bulletData,
|
|
153
|
+
cartridgeCaseData,
|
|
154
|
+
shotshellData,
|
|
155
|
+
indexType,
|
|
156
|
+
indexNumber,
|
|
157
|
+
indexColor,
|
|
158
|
+
supportLevel,
|
|
159
|
+
includeConfirmation,
|
|
160
|
+
additionalNotes,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
setIsDirty(nextSnapshot !== savedSnapshot);
|
|
164
|
+
}, DIRTY_CHECK_DEBOUNCE_MS);
|
|
165
|
+
|
|
166
|
+
return () => {
|
|
167
|
+
window.clearTimeout(timeoutId);
|
|
168
|
+
};
|
|
169
|
+
}, [
|
|
170
|
+
additionalNotes,
|
|
171
|
+
bulletData,
|
|
172
|
+
cartridgeCaseData,
|
|
173
|
+
caseFontColor,
|
|
174
|
+
classNote,
|
|
175
|
+
classType,
|
|
176
|
+
customClass,
|
|
177
|
+
hasLoadedSnapshot,
|
|
178
|
+
hasSubclass,
|
|
179
|
+
includeConfirmation,
|
|
180
|
+
indexColor,
|
|
181
|
+
indexNumber,
|
|
182
|
+
indexType,
|
|
183
|
+
leftCase,
|
|
184
|
+
leftItem,
|
|
185
|
+
rightCase,
|
|
186
|
+
rightItem,
|
|
187
|
+
savedSnapshot,
|
|
188
|
+
shotshellData,
|
|
189
|
+
supportLevel,
|
|
190
|
+
]);
|
|
74
191
|
|
|
75
192
|
useEffect(() => {
|
|
76
193
|
const loadExistingNotes = async () => {
|
|
@@ -79,6 +196,9 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
79
196
|
setIsLoading(true);
|
|
80
197
|
setLoadError(undefined);
|
|
81
198
|
setIsConfirmedImage(false);
|
|
199
|
+
setHasLoadedSnapshot(false);
|
|
200
|
+
setIsDirty(false);
|
|
201
|
+
onDirtyChange?.(false);
|
|
82
202
|
|
|
83
203
|
try {
|
|
84
204
|
const existingNotes = await getNotes(user, currentCase, imageId);
|
|
@@ -106,19 +226,66 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
106
226
|
setSupportLevel(existingNotes.supportLevel || '');
|
|
107
227
|
setIncludeConfirmation(existingNotes.includeConfirmation);
|
|
108
228
|
setAdditionalNotes(existingNotes.additionalNotes || '');
|
|
229
|
+
|
|
230
|
+
setSavedSnapshot(serializeNotesSnapshot({
|
|
231
|
+
leftCase: existingNotes.leftCase || '',
|
|
232
|
+
rightCase: existingNotes.rightCase || '',
|
|
233
|
+
leftItem: existingNotes.leftItem || '',
|
|
234
|
+
rightItem: existingNotes.rightItem || '',
|
|
235
|
+
caseFontColor: existingNotes.caseFontColor || '',
|
|
236
|
+
classType: existingNotes.classType || '',
|
|
237
|
+
customClass: existingNotes.customClass || '',
|
|
238
|
+
classNote: existingNotes.classNote || '',
|
|
239
|
+
hasSubclass: existingNotes.hasSubclass ?? false,
|
|
240
|
+
bulletData: existingNotes.bulletData,
|
|
241
|
+
cartridgeCaseData: existingNotes.cartridgeCaseData,
|
|
242
|
+
shotshellData: existingNotes.shotshellData,
|
|
243
|
+
indexType: existingNotes.indexType || 'color',
|
|
244
|
+
indexNumber: existingNotes.indexNumber || '',
|
|
245
|
+
indexColor: existingNotes.indexColor || '',
|
|
246
|
+
supportLevel: existingNotes.supportLevel || '',
|
|
247
|
+
includeConfirmation: existingNotes.includeConfirmation,
|
|
248
|
+
additionalNotes: existingNotes.additionalNotes || ''
|
|
249
|
+
}));
|
|
109
250
|
} else {
|
|
110
251
|
setIsConfirmedImage(false);
|
|
252
|
+
|
|
253
|
+
setSavedSnapshot(serializeNotesSnapshot({
|
|
254
|
+
leftCase: '',
|
|
255
|
+
rightCase: '',
|
|
256
|
+
leftItem: '',
|
|
257
|
+
rightItem: '',
|
|
258
|
+
caseFontColor: '',
|
|
259
|
+
classType: '',
|
|
260
|
+
customClass: '',
|
|
261
|
+
classNote: '',
|
|
262
|
+
hasSubclass: false,
|
|
263
|
+
bulletData: undefined,
|
|
264
|
+
cartridgeCaseData: undefined,
|
|
265
|
+
shotshellData: undefined,
|
|
266
|
+
indexType: 'color',
|
|
267
|
+
indexNumber: '',
|
|
268
|
+
indexColor: '',
|
|
269
|
+
supportLevel: '',
|
|
270
|
+
includeConfirmation: false,
|
|
271
|
+
additionalNotes: ''
|
|
272
|
+
}));
|
|
111
273
|
}
|
|
112
274
|
} catch (error) {
|
|
113
275
|
setLoadError('Failed to load existing notes');
|
|
114
276
|
console.error('Error loading notes:', error);
|
|
115
277
|
} finally {
|
|
116
278
|
setIsLoading(false);
|
|
279
|
+
setHasLoadedSnapshot(true);
|
|
117
280
|
}
|
|
118
281
|
};
|
|
119
282
|
|
|
120
283
|
loadExistingNotes();
|
|
121
|
-
}, [imageId, currentCase, user]);
|
|
284
|
+
}, [imageId, currentCase, onDirtyChange, user]);
|
|
285
|
+
|
|
286
|
+
useIsomorphicLayoutEffect(() => {
|
|
287
|
+
onDirtyChange?.(isDirty);
|
|
288
|
+
}, [isDirty, onDirtyChange]);
|
|
122
289
|
|
|
123
290
|
useEffect(() => {
|
|
124
291
|
if (useCurrentCaseLeft) {
|
|
@@ -129,11 +296,11 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
129
296
|
}
|
|
130
297
|
}, [useCurrentCaseLeft, useCurrentCaseRight, currentCase]);
|
|
131
298
|
|
|
132
|
-
const handleSave = async () => {
|
|
299
|
+
const handleSave = useCallback(async (): Promise<boolean> => {
|
|
133
300
|
|
|
134
301
|
if (!imageId) {
|
|
135
302
|
console.error('No image selected');
|
|
136
|
-
return;
|
|
303
|
+
return false;
|
|
137
304
|
}
|
|
138
305
|
|
|
139
306
|
let existingData: AnnotationData | null = null;
|
|
@@ -145,8 +312,12 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
145
312
|
if (existingData?.confirmationData) {
|
|
146
313
|
setIsConfirmedImage(true);
|
|
147
314
|
notificationHandler('This image is confirmed. Notes cannot be modified.', 'error');
|
|
148
|
-
return;
|
|
315
|
+
return false;
|
|
149
316
|
}
|
|
317
|
+
|
|
318
|
+
const normalizedBulletData = normalizeNestedAnnotationData(bulletData);
|
|
319
|
+
const normalizedCartridgeCaseData = normalizeNestedAnnotationData(cartridgeCaseData);
|
|
320
|
+
const normalizedShotshellData = normalizeNestedAnnotationData(shotshellData);
|
|
150
321
|
|
|
151
322
|
// Create updated annotation data, preserving box annotations and earliest timestamp
|
|
152
323
|
const now = new Date().toISOString();
|
|
@@ -163,9 +334,9 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
163
334
|
customClass: customClass,
|
|
164
335
|
classNote: classNote || undefined,
|
|
165
336
|
hasSubclass: hasSubclass,
|
|
166
|
-
bulletData:
|
|
167
|
-
cartridgeCaseData:
|
|
168
|
-
shotshellData:
|
|
337
|
+
bulletData: normalizedBulletData,
|
|
338
|
+
cartridgeCaseData: normalizedCartridgeCaseData,
|
|
339
|
+
shotshellData: normalizedShotshellData,
|
|
169
340
|
|
|
170
341
|
// Index Information
|
|
171
342
|
indexType: indexType,
|
|
@@ -207,11 +378,36 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
207
378
|
);
|
|
208
379
|
|
|
209
380
|
notificationHandler('Notes saved successfully.', 'success');
|
|
381
|
+
|
|
382
|
+
setSavedSnapshot(serializeNotesSnapshot({
|
|
383
|
+
leftCase,
|
|
384
|
+
rightCase,
|
|
385
|
+
leftItem,
|
|
386
|
+
rightItem,
|
|
387
|
+
caseFontColor,
|
|
388
|
+
classType,
|
|
389
|
+
customClass,
|
|
390
|
+
classNote,
|
|
391
|
+
hasSubclass,
|
|
392
|
+
bulletData,
|
|
393
|
+
cartridgeCaseData,
|
|
394
|
+
shotshellData,
|
|
395
|
+
indexType,
|
|
396
|
+
indexNumber,
|
|
397
|
+
indexColor,
|
|
398
|
+
supportLevel,
|
|
399
|
+
includeConfirmation,
|
|
400
|
+
additionalNotes,
|
|
401
|
+
}));
|
|
402
|
+
setIsDirty(false);
|
|
403
|
+
onDirtyChange?.(false);
|
|
210
404
|
|
|
211
405
|
// Refresh annotation data after saving notes
|
|
212
406
|
if (onAnnotationRefresh) {
|
|
213
407
|
onAnnotationRefresh();
|
|
214
408
|
}
|
|
409
|
+
|
|
410
|
+
return true;
|
|
215
411
|
} catch (error) {
|
|
216
412
|
console.error('Failed to save notes:', error);
|
|
217
413
|
const errorMessage = error instanceof Error ? error.message : '';
|
|
@@ -237,8 +433,44 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
237
433
|
} catch (auditError) {
|
|
238
434
|
console.error('Failed to log annotation edit audit:', auditError);
|
|
239
435
|
}
|
|
436
|
+
|
|
437
|
+
return false;
|
|
240
438
|
}
|
|
241
|
-
}
|
|
439
|
+
}, [
|
|
440
|
+
additionalNotes,
|
|
441
|
+
bulletData,
|
|
442
|
+
cartridgeCaseData,
|
|
443
|
+
caseFontColor,
|
|
444
|
+
classNote,
|
|
445
|
+
classType,
|
|
446
|
+
currentCase,
|
|
447
|
+
customClass,
|
|
448
|
+
hasSubclass,
|
|
449
|
+
imageId,
|
|
450
|
+
includeConfirmation,
|
|
451
|
+
indexColor,
|
|
452
|
+
indexNumber,
|
|
453
|
+
indexType,
|
|
454
|
+
leftCase,
|
|
455
|
+
leftItem,
|
|
456
|
+
notificationHandler,
|
|
457
|
+
onAnnotationRefresh,
|
|
458
|
+
onDirtyChange,
|
|
459
|
+
originalFileName,
|
|
460
|
+
rightCase,
|
|
461
|
+
rightItem,
|
|
462
|
+
shotshellData,
|
|
463
|
+
supportLevel,
|
|
464
|
+
user,
|
|
465
|
+
]);
|
|
466
|
+
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
onRegisterSaveHandler?.(handleSave);
|
|
469
|
+
|
|
470
|
+
return () => {
|
|
471
|
+
onRegisterSaveHandler?.(null);
|
|
472
|
+
};
|
|
473
|
+
}, [handleSave, onRegisterSaveHandler]);
|
|
242
474
|
|
|
243
475
|
return (
|
|
244
476
|
<div className={`${styles.notesEditorForm} ${styles.editorLayout}`}>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
3
|
import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
3
4
|
import { NotesEditorForm } from './notes-editor-form';
|
|
4
5
|
import styles from './notes.module.css';
|
|
@@ -26,21 +27,151 @@ export const NotesEditorModal = ({
|
|
|
26
27
|
isUploading = false,
|
|
27
28
|
showNotification,
|
|
28
29
|
}: NotesEditorModalProps) => {
|
|
30
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
31
|
+
const [isCloseAlertOpen, setIsCloseAlertOpen] = useState(false);
|
|
32
|
+
const [isSavingBeforeClose, setIsSavingBeforeClose] = useState(false);
|
|
33
|
+
const [saveHandler, setSaveHandler] = useState<(() => Promise<boolean>) | null>(null);
|
|
34
|
+
const closeAlertRef = useRef<HTMLDivElement | null>(null);
|
|
35
|
+
const saveAndCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
36
|
+
const closeAlertTitleId = useId();
|
|
37
|
+
const closeAlertDescriptionId = useId();
|
|
38
|
+
|
|
39
|
+
const handleCloseAttempt = useCallback(() => {
|
|
40
|
+
if (hasUnsavedChanges) {
|
|
41
|
+
setIsCloseAlertOpen(true);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onClose();
|
|
46
|
+
}, [hasUnsavedChanges, onClose]);
|
|
47
|
+
|
|
48
|
+
const handleDirtyChange = useCallback((isDirty: boolean) => {
|
|
49
|
+
setHasUnsavedChanges((previous) => (previous === isDirty ? previous : isDirty));
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleRegisterSaveHandler = useCallback((handler: (() => Promise<boolean>) | null) => {
|
|
53
|
+
setSaveHandler((previous) => (previous === handler ? previous : handler));
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
29
56
|
const {
|
|
30
57
|
overlayProps,
|
|
31
58
|
getCloseButtonProps,
|
|
32
59
|
} = useOverlayDismiss({
|
|
33
60
|
isOpen,
|
|
34
|
-
onClose,
|
|
61
|
+
onClose: handleCloseAttempt,
|
|
62
|
+
closeOnEscape: !isCloseAlertOpen,
|
|
35
63
|
});
|
|
36
64
|
|
|
65
|
+
const handleDiscardAndClose = () => {
|
|
66
|
+
setHasUnsavedChanges(false);
|
|
67
|
+
setIsCloseAlertOpen(false);
|
|
68
|
+
onClose();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleCancelClose = () => {
|
|
72
|
+
setIsCloseAlertOpen(false);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const canSaveBeforeClose = !!saveHandler && !isSavingBeforeClose;
|
|
76
|
+
|
|
77
|
+
const handleSaveBeforeClose = async () => {
|
|
78
|
+
if (!saveHandler) {
|
|
79
|
+
showNotification?.('Save is not ready yet. Please wait a moment and try again.', 'warning');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setIsSavingBeforeClose(true);
|
|
84
|
+
let didSave = false;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
didSave = await saveHandler();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Failed to save notes before closing:', error);
|
|
90
|
+
showNotification?.('Failed to save notes. Please try again.', 'error');
|
|
91
|
+
didSave = false;
|
|
92
|
+
} finally {
|
|
93
|
+
setIsSavingBeforeClose(false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!didSave) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setHasUnsavedChanges(false);
|
|
101
|
+
setIsCloseAlertOpen(false);
|
|
102
|
+
onClose();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!isCloseAlertOpen) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
111
|
+
saveAndCloseButtonRef.current?.focus();
|
|
112
|
+
|
|
113
|
+
const handleAlertKeyDown = (event: KeyboardEvent) => {
|
|
114
|
+
if (event.key === 'Escape') {
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
setIsCloseAlertOpen(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.key !== 'Tab') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const focusContainer = closeAlertRef.current;
|
|
125
|
+
if (!focusContainer) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const focusableElements = Array.from(
|
|
130
|
+
focusContainer.querySelectorAll<HTMLElement>(
|
|
131
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
if (focusableElements.length === 0) {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const first = focusableElements[0];
|
|
141
|
+
const last = focusableElements[focusableElements.length - 1];
|
|
142
|
+
const active = document.activeElement as HTMLElement | null;
|
|
143
|
+
|
|
144
|
+
if (event.shiftKey && active === first) {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
last.focus();
|
|
147
|
+
} else if (!event.shiftKey && active === last) {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
first.focus();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
document.addEventListener('keydown', handleAlertKeyDown);
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
document.removeEventListener('keydown', handleAlertKeyDown);
|
|
157
|
+
previouslyFocused?.focus();
|
|
158
|
+
};
|
|
159
|
+
}, [isCloseAlertOpen]);
|
|
160
|
+
|
|
37
161
|
if (!isOpen) {
|
|
38
162
|
return null;
|
|
39
163
|
}
|
|
40
164
|
|
|
41
165
|
return (
|
|
42
166
|
<div className={styles.overlay} aria-label="Close image notes dialog" {...overlayProps}>
|
|
43
|
-
<div
|
|
167
|
+
<div
|
|
168
|
+
className={styles.editorModal}
|
|
169
|
+
role="dialog"
|
|
170
|
+
aria-modal={isCloseAlertOpen ? undefined : true}
|
|
171
|
+
aria-label="Image Notes"
|
|
172
|
+
aria-hidden={isCloseAlertOpen}
|
|
173
|
+
inert={isCloseAlertOpen}
|
|
174
|
+
>
|
|
44
175
|
<div className={styles.editorModalHeader}>
|
|
45
176
|
<h2 className={styles.editorModalTitle}>Image Notes</h2>
|
|
46
177
|
<button className={styles.editorModalCloseButton} {...getCloseButtonProps({ ariaLabel: 'Close image notes dialog' })}>
|
|
@@ -56,9 +187,57 @@ export const NotesEditorModal = ({
|
|
|
56
187
|
originalFileName={originalFileName}
|
|
57
188
|
isUploading={isUploading}
|
|
58
189
|
showNotification={showNotification}
|
|
190
|
+
onDirtyChange={handleDirtyChange}
|
|
191
|
+
onRegisterSaveHandler={handleRegisterSaveHandler}
|
|
59
192
|
/>
|
|
60
193
|
</div>
|
|
61
194
|
</div>
|
|
195
|
+
{isCloseAlertOpen && (
|
|
196
|
+
<div className={styles.modalOverlay}>
|
|
197
|
+
<div
|
|
198
|
+
className={`${styles.modal} ${styles.unsavedChangesModal}`}
|
|
199
|
+
ref={closeAlertRef}
|
|
200
|
+
role="alertdialog"
|
|
201
|
+
aria-modal="true"
|
|
202
|
+
aria-labelledby={closeAlertTitleId}
|
|
203
|
+
aria-describedby={closeAlertDescriptionId}
|
|
204
|
+
>
|
|
205
|
+
<h3 id={closeAlertTitleId} className={styles.modalTitle}>You have unsaved notes!</h3>
|
|
206
|
+
<p id={closeAlertDescriptionId} className={styles.unsavedChangesMessage}>
|
|
207
|
+
You have unsaved changes to notes and data. Save before closing?
|
|
208
|
+
</p>
|
|
209
|
+
<div className={styles.unsavedChangesActions}>
|
|
210
|
+
<div className={styles.unsavedChangesPrimaryRow}>
|
|
211
|
+
<button
|
|
212
|
+
ref={saveAndCloseButtonRef}
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={handleSaveBeforeClose}
|
|
215
|
+
className={`${styles.saveButton} ${styles.unsavedChangesPrimaryAction}`}
|
|
216
|
+
disabled={!canSaveBeforeClose}
|
|
217
|
+
>
|
|
218
|
+
{isSavingBeforeClose ? 'Saving...' : 'Save and Close'}
|
|
219
|
+
</button>
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
onClick={handleDiscardAndClose}
|
|
223
|
+
className={`${styles.cancelButton} ${styles.unsavedChangesPrimaryAction}`}
|
|
224
|
+
disabled={isSavingBeforeClose}
|
|
225
|
+
>
|
|
226
|
+
Close Without Saving
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={handleCancelClose}
|
|
232
|
+
className={styles.secondaryButton}
|
|
233
|
+
disabled={isSavingBeforeClose}
|
|
234
|
+
>
|
|
235
|
+
Continue Editing
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
62
241
|
</div>
|
|
63
242
|
);
|
|
64
243
|
};
|
|
@@ -688,6 +688,76 @@ textarea:focus {
|
|
|
688
688
|
justify-content: flex-end;
|
|
689
689
|
}
|
|
690
690
|
|
|
691
|
+
.modalButtons .saveButton,
|
|
692
|
+
.modalButtons .cancelButton {
|
|
693
|
+
min-height: 42px;
|
|
694
|
+
margin: 0;
|
|
695
|
+
display: inline-flex;
|
|
696
|
+
align-items: center;
|
|
697
|
+
justify-content: center;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.classDetailsModalButtons .classDetailsModalAction {
|
|
701
|
+
min-height: 42px;
|
|
702
|
+
margin: 0;
|
|
703
|
+
display: inline-flex;
|
|
704
|
+
align-items: center;
|
|
705
|
+
justify-content: center;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.unsavedChangesModal {
|
|
709
|
+
max-width: 520px;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.unsavedChangesModal .modalTitle {
|
|
713
|
+
margin-top: 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.unsavedChangesMessage {
|
|
717
|
+
margin: 0.75rem;
|
|
718
|
+
color: #495057;
|
|
719
|
+
line-height: 1.5;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.unsavedChangesActions {
|
|
723
|
+
display: flex;
|
|
724
|
+
flex-direction: column;
|
|
725
|
+
gap: 0.75rem;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.unsavedChangesPrimaryRow {
|
|
729
|
+
display: grid;
|
|
730
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
731
|
+
gap: 0.75rem;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.unsavedChangesPrimaryAction {
|
|
735
|
+
width: 100%;
|
|
736
|
+
margin: 0;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.unsavedChangesPrimaryRow .cancelButton,
|
|
740
|
+
.unsavedChangesPrimaryRow .saveButton {
|
|
741
|
+
width: 100%;
|
|
742
|
+
margin: 0;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.secondaryButton {
|
|
746
|
+
width: 100%;
|
|
747
|
+
padding: 0.75rem;
|
|
748
|
+
border: 1.5px solid #ced4da;
|
|
749
|
+
border-radius: 6px;
|
|
750
|
+
background: white;
|
|
751
|
+
color: #495057;
|
|
752
|
+
font-weight: 500;
|
|
753
|
+
cursor: pointer;
|
|
754
|
+
transition: all 0.2s;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
.secondaryButton:hover {
|
|
758
|
+
background-color: #f8f9fa;
|
|
759
|
+
}
|
|
760
|
+
|
|
691
761
|
.cancelButton {
|
|
692
762
|
width: 30%;
|
|
693
763
|
padding: 0.75rem;
|
|
@@ -705,6 +775,13 @@ textarea:focus {
|
|
|
705
775
|
background-color: #bd2130;
|
|
706
776
|
}
|
|
707
777
|
|
|
778
|
+
.cancelButton:disabled,
|
|
779
|
+
.saveButton:disabled,
|
|
780
|
+
.secondaryButton:disabled {
|
|
781
|
+
opacity: 0.65;
|
|
782
|
+
cursor: not-allowed;
|
|
783
|
+
}
|
|
784
|
+
|
|
708
785
|
.saveButton {
|
|
709
786
|
width: 100%;
|
|
710
787
|
padding: 0.75rem;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@striae-org/striae",
|
|
3
|
-
"version": "5.5.
|
|
3
|
+
"version": "5.5.1",
|
|
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",
|
|
@@ -105,8 +105,8 @@
|
|
|
105
105
|
"isbot": "^5.1.37",
|
|
106
106
|
"jszip": "^3.10.1",
|
|
107
107
|
"qrcode": "^1.5.4",
|
|
108
|
-
"react": "^19.2.
|
|
109
|
-
"react-dom": "^19.2.
|
|
108
|
+
"react": "^19.2.5",
|
|
109
|
+
"react-dom": "^19.2.5",
|
|
110
110
|
"react-router": "^7.14.0"
|
|
111
111
|
},
|
|
112
112
|
"devDependencies": {
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
"typescript": "^5.9.3",
|
|
129
129
|
"vite": "^7.3.2",
|
|
130
130
|
"vite-tsconfig-paths": "^6.1.1",
|
|
131
|
-
"wrangler": "^4.81.
|
|
131
|
+
"wrangler": "^4.81.1"
|
|
132
132
|
},
|
|
133
133
|
"overrides": {
|
|
134
134
|
"@tootallnate/once": "3.0.1",
|
|
@@ -7,6 +7,14 @@ const markdownFiles = [
|
|
|
7
7
|
// Add other markdown files that need version updates
|
|
8
8
|
];
|
|
9
9
|
|
|
10
|
+
const workerDirs = [
|
|
11
|
+
'workers/audit-worker',
|
|
12
|
+
'workers/data-worker',
|
|
13
|
+
'workers/image-worker',
|
|
14
|
+
'workers/pdf-worker',
|
|
15
|
+
'workers/user-worker',
|
|
16
|
+
];
|
|
17
|
+
|
|
10
18
|
function updateMarkdownVersions() {
|
|
11
19
|
console.log(`📝 Updating markdown files with version ${packageJson.version}...`);
|
|
12
20
|
|
|
@@ -31,8 +39,57 @@ function updateMarkdownVersions() {
|
|
|
31
39
|
console.error(`❌ Error updating ${filePath}:`, error.message);
|
|
32
40
|
}
|
|
33
41
|
});
|
|
42
|
+
|
|
43
|
+
console.log(`📦 Updating worker package.json files with version ${packageJson.version}...`);
|
|
44
|
+
|
|
45
|
+
workerDirs.forEach(workerDir => {
|
|
46
|
+
const pkgPath = path.join(__dirname, '..', workerDir, 'package.json');
|
|
47
|
+
const lockPath = path.join(__dirname, '..', workerDir, 'package-lock.json');
|
|
48
|
+
|
|
49
|
+
// --- Update package.json ---
|
|
50
|
+
if (!fs.existsSync(pkgPath)) {
|
|
51
|
+
console.log(`⚠️ Skipping ${workerDir}/package.json (file not found)`);
|
|
52
|
+
} else {
|
|
53
|
+
try {
|
|
54
|
+
const workerPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
55
|
+
workerPkg.version = packageJson.version;
|
|
56
|
+
fs.writeFileSync(pkgPath, JSON.stringify(workerPkg, null, 2) + '\n');
|
|
57
|
+
console.log(`✅ Updated ${workerDir}/package.json`);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(`❌ Error updating ${workerDir}/package.json:`, error.message);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Update package-lock.json ---
|
|
64
|
+
// Lockfile v2/v3 stores the version in two places:
|
|
65
|
+
// - Top-level `version` field
|
|
66
|
+
// - `packages[""].version` (the self-referencing root entry)
|
|
67
|
+
// Both must match package.json to pass `npm ci` consistency checks.
|
|
68
|
+
if (!fs.existsSync(lockPath)) {
|
|
69
|
+
console.log(
|
|
70
|
+
`⚠️ No package-lock.json found in ${workerDir} — run \`npm install\` there to generate one.`
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
try {
|
|
74
|
+
const lockData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
75
|
+
|
|
76
|
+
if ('version' in lockData) {
|
|
77
|
+
lockData.version = packageJson.version;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (lockData.packages && '' in lockData.packages) {
|
|
81
|
+
lockData.packages[''].version = packageJson.version;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + '\n');
|
|
85
|
+
console.log(`✅ Updated ${workerDir}/package-lock.json`);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error(`❌ Error updating ${workerDir}/package-lock.json:`, error.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
34
91
|
|
|
35
|
-
console.log('🎉
|
|
92
|
+
console.log('🎉 Version update complete!');
|
|
36
93
|
}
|
|
37
94
|
|
|
38
95
|
// Run if called directly
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "audit-worker",
|
|
3
|
+
"version": "5.5.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"deploy": "wrangler deploy",
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"start": "wrangler dev"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"wrangler": "^4.81.1"
|
|
12
|
+
},
|
|
13
|
+
"overrides": {
|
|
14
|
+
"undici": "7.24.1",
|
|
15
|
+
"yauzl": "3.2.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "data-worker",
|
|
3
|
-
"version": "
|
|
4
|
-
"private": true,
|
|
5
|
-
"scripts": {
|
|
6
|
-
"deploy": "wrangler deploy",
|
|
7
|
-
"dev": "wrangler dev",
|
|
8
|
-
"start": "wrangler dev"
|
|
9
|
-
},
|
|
10
|
-
"devDependencies": {
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "data-worker",
|
|
3
|
+
"version": "5.5.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"deploy": "wrangler deploy",
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"start": "wrangler dev"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"wrangler": "^4.81.1"
|
|
12
|
+
},
|
|
13
|
+
"overrides": {
|
|
14
|
+
"undici": "7.24.1",
|
|
15
|
+
"yauzl": "3.2.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "image-worker",
|
|
3
|
+
"version": "5.5.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"deploy": "wrangler deploy",
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"start": "wrangler dev"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"wrangler": "^4.81.1"
|
|
12
|
+
},
|
|
13
|
+
"overrides": {
|
|
14
|
+
"undici": "7.24.1",
|
|
15
|
+
"yauzl": "3.2.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pdf-worker",
|
|
3
|
+
"version": "5.5.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"generate:assets": "node scripts/generate-assets.js",
|
|
7
|
+
"deploy": "wrangler deploy",
|
|
8
|
+
"dev": "wrangler dev",
|
|
9
|
+
"start": "wrangler dev"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"wrangler": "^4.81.1"
|
|
13
|
+
},
|
|
14
|
+
"overrides": {
|
|
15
|
+
"undici": "7.24.1",
|
|
16
|
+
"yauzl": "3.2.1"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -3,13 +3,34 @@ import { buildRepeatedChromePdfOptions, escapeHtml } from './report-layout';
|
|
|
3
3
|
|
|
4
4
|
const safeText = (value: unknown): string => escapeHtml(String(value ?? ''));
|
|
5
5
|
|
|
6
|
-
const formatTimestamp = (timestamp: string): string => {
|
|
6
|
+
const formatTimestamp = (timestamp: string, timezone?: string): string => {
|
|
7
7
|
const parsed = new Date(timestamp);
|
|
8
8
|
if (Number.isNaN(parsed.getTime())) {
|
|
9
9
|
return timestamp;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
if (!timezone) {
|
|
13
|
+
return parsed.toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
18
|
+
timeZone: timezone,
|
|
19
|
+
month: '2-digit',
|
|
20
|
+
day: '2-digit',
|
|
21
|
+
year: 'numeric',
|
|
22
|
+
hour: '2-digit',
|
|
23
|
+
minute: '2-digit',
|
|
24
|
+
second: '2-digit',
|
|
25
|
+
hour12: false,
|
|
26
|
+
timeZoneName: 'short'
|
|
27
|
+
}).formatToParts(parsed);
|
|
28
|
+
|
|
29
|
+
const get = (type: string): string => parts.find(p => p.type === type)?.value ?? '';
|
|
30
|
+
return `${get('month')}/${get('day')}/${get('year')} ${get('hour')}:${get('minute')}:${get('second')} ${get('timeZoneName')}`;
|
|
31
|
+
} catch {
|
|
32
|
+
return parsed.toISOString();
|
|
33
|
+
}
|
|
13
34
|
};
|
|
14
35
|
|
|
15
36
|
const renderEntryDetailsSummary = (entry: Record<string, unknown>): string => {
|
|
@@ -71,6 +92,7 @@ export const getAuditTrailPayload = (data: PDFGenerationData): AuditTrailReportP
|
|
|
71
92
|
export const renderAuditTrailReport = (data: PDFGenerationData): string => {
|
|
72
93
|
const payload = getAuditTrailPayload(data);
|
|
73
94
|
const entries = payload.entries || [];
|
|
95
|
+
const timezone = data.userTimezone;
|
|
74
96
|
|
|
75
97
|
const entrySections = entries.map((entry, index) => {
|
|
76
98
|
const entryRecord = entry as Record<string, unknown>;
|
|
@@ -84,7 +106,7 @@ export const renderAuditTrailReport = (data: PDFGenerationData): string => {
|
|
|
84
106
|
<section class="entry-section">
|
|
85
107
|
<h3 class="entry-title">Entry ${index + 1} of ${entries.length}</h3>
|
|
86
108
|
<div class="entry-core-grid">
|
|
87
|
-
<div><strong>Timestamp:</strong> ${safeText(formatTimestamp(timestamp))}</div>
|
|
109
|
+
<div><strong>Timestamp:</strong> ${safeText(formatTimestamp(timestamp, timezone))}</div>
|
|
88
110
|
<div><strong>Action:</strong> ${safeText(action)}</div>
|
|
89
111
|
<div><strong>Result:</strong> ${safeText(result)}</div>
|
|
90
112
|
<div><strong>User Email:</strong> ${safeText(userEmail)}</div>
|
|
@@ -195,9 +217,9 @@ export const renderAuditTrailReport = (data: PDFGenerationData): string => {
|
|
|
195
217
|
<h1>Case Audit Trail Report</h1>
|
|
196
218
|
<div class="summary-grid">
|
|
197
219
|
<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>
|
|
220
|
+
<div><strong>Exported At:</strong> ${safeText(formatTimestamp(payload.exportedAt, timezone))}</div>
|
|
221
|
+
<div><strong>Range Start:</strong> ${safeText(formatTimestamp(payload.exportRangeStart, timezone))}</div>
|
|
222
|
+
<div><strong>Range End:</strong> ${safeText(formatTimestamp(payload.exportRangeEnd, timezone))}</div>
|
|
201
223
|
<div><strong>Total Entries (All Parts):</strong> ${safeText(payload.totalEntries)}</div>
|
|
202
224
|
<div><strong>This Part:</strong> ${safeText(payload.chunkIndex)} of ${safeText(payload.totalChunks)}</div>
|
|
203
225
|
<div><strong>Entries in Part:</strong> ${safeText(entries.length)}</div>
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "user-worker",
|
|
3
|
-
"version": "
|
|
4
|
-
"private": true,
|
|
5
|
-
"scripts": {
|
|
6
|
-
"deploy": "wrangler deploy",
|
|
7
|
-
"dev": "wrangler dev",
|
|
8
|
-
"start": "wrangler dev"
|
|
9
|
-
},
|
|
10
|
-
"devDependencies": {
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "user-worker",
|
|
3
|
+
"version": "5.5.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"deploy": "wrangler deploy",
|
|
7
|
+
"dev": "wrangler dev",
|
|
8
|
+
"start": "wrangler dev"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"wrangler": "^4.81.1"
|
|
12
|
+
},
|
|
13
|
+
"overrides": {
|
|
14
|
+
"undici": "7.24.1",
|
|
15
|
+
"yauzl": "3.2.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/wrangler.toml.example
CHANGED