@striae-org/striae 4.0.3 → 4.2.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/.env.example +8 -0
- package/app/components/actions/case-export/core-export.ts +14 -8
- package/app/components/actions/case-export/data-processing.ts +1 -0
- package/app/components/actions/case-export/download-handlers.ts +7 -0
- package/app/components/actions/case-export/metadata-helpers.ts +2 -1
- package/app/components/actions/case-import/confirmation-import.ts +12 -2
- package/app/components/actions/case-import/orchestrator.ts +78 -32
- package/app/components/actions/case-import/storage-operations.ts +97 -8
- package/app/components/actions/case-import/zip-processing.ts +159 -86
- package/app/components/actions/case-manage.ts +430 -8
- package/app/components/actions/confirm-export.ts +13 -4
- package/app/components/actions/generate-pdf.ts +10 -2
- package/app/components/actions/image-manage.ts +77 -44
- package/app/components/audit/user-audit-viewer.tsx +137 -945
- package/app/components/audit/user-audit.module.css +41 -0
- package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
- package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
- package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
- package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
- package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
- package/app/components/audit/viewer/types.ts +1 -0
- package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
- package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
- package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
- package/app/components/auth/mfa-enrollment.module.css +13 -5
- package/app/components/auth/mfa-verification.module.css +13 -5
- package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
- package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
- package/app/components/canvas/canvas.module.css +64 -54
- package/app/components/canvas/canvas.tsx +17 -16
- package/app/components/canvas/confirmation/confirmation.module.css +1 -0
- package/app/components/canvas/confirmation/confirmation.tsx +17 -47
- package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
- package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
- package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
- package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
- package/app/components/navbar/navbar.module.css +447 -0
- package/app/components/navbar/navbar.tsx +377 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
- package/app/components/sidebar/case-export/case-export.module.css +1 -0
- package/app/components/sidebar/case-export/case-export.tsx +14 -77
- package/app/components/sidebar/case-import/case-import.module.css +25 -0
- package/app/components/sidebar/case-import/case-import.tsx +64 -40
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
- package/app/components/sidebar/cases/cases-modal.module.css +45 -9
- package/app/components/sidebar/cases/cases-modal.tsx +16 -16
- package/app/components/sidebar/cases/cases.module.css +62 -21
- package/app/components/sidebar/files/files-modal.module.css +46 -10
- package/app/components/sidebar/files/files-modal.tsx +22 -23
- package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
- package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
- package/app/components/sidebar/notes/notes-modal.tsx +18 -17
- package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
- package/app/components/sidebar/notes/notes.module.css +155 -0
- package/app/components/sidebar/sidebar-container.tsx +15 -28
- package/app/components/sidebar/sidebar.module.css +7 -71
- package/app/components/sidebar/sidebar.tsx +24 -125
- package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
- package/app/components/toast/toast.module.css +2 -1
- package/app/components/toast/toast.tsx +16 -11
- package/app/components/user/delete-account.tsx +10 -31
- package/app/components/user/inactivity-warning.module.css +9 -6
- package/app/components/user/inactivity-warning.tsx +15 -2
- package/app/components/user/manage-profile.module.css +2 -0
- package/app/components/user/manage-profile.tsx +108 -40
- package/app/hooks/useOverlayDismiss.ts +116 -0
- package/app/routes/auth/login.example.tsx +19 -8
- package/app/routes/auth/login.tsx +785 -774
- package/app/routes/auth/passwordReset.module.css +23 -13
- package/app/routes/striae/striae.module.css +10 -3
- package/app/routes/striae/striae.tsx +477 -31
- package/app/routes.ts +7 -0
- package/app/services/audit/audit-export-csv.ts +2 -0
- package/app/services/audit/audit.service.ts +202 -32
- package/app/services/audit/builders/audit-entry-builder.ts +2 -1
- package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
- package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
- package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
- package/app/services/audit/builders/index.ts +1 -0
- package/app/types/audit.ts +5 -2
- package/app/types/case.ts +29 -0
- package/app/types/import.ts +3 -0
- package/app/types/user.ts +1 -0
- package/app/utils/data/permissions.ts +17 -1
- package/app/utils/forensics/audit-export-signature.ts +5 -1
- package/app/utils/forensics/confirmation-signature.ts +3 -0
- package/app/utils/forensics/export-verification.ts +497 -22
- package/functions/api/pdf/[[path]].ts +32 -1
- package/load-context.ts +9 -0
- package/package.json +6 -2
- package/primershear.emails.example +6 -0
- package/scripts/deploy-pages-secrets.sh +6 -0
- package/scripts/deploy-primershear-emails.sh +167 -0
- package/worker-configuration.d.ts +7493 -7491
- package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/worker-configuration.d.ts +7448 -11323
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/worker-configuration.d.ts +7447 -11322
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
- package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
- package/workers/pdf-worker/src/report-types.ts +3 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/user-worker.example.ts +6 -1
- package/workers/user-worker/worker-configuration.d.ts +7448 -11323
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/public/.well-known/keybase.txt +0 -56
|
@@ -1,22 +1,46 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { SidebarContainer } from '~/components/sidebar/sidebar-container';
|
|
4
|
+
import { Navbar } from '~/components/navbar/navbar';
|
|
5
|
+
import { RenameCaseModal } from '~/components/navbar/case-modals/rename-case-modal';
|
|
6
|
+
import { ArchiveCaseModal } from '~/components/navbar/case-modals/archive-case-modal';
|
|
7
|
+
import { OpenCaseModal } from '~/components/navbar/case-modals/open-case-modal';
|
|
4
8
|
import { Toolbar } from '~/components/toolbar/toolbar';
|
|
5
9
|
import { Canvas } from '~/components/canvas/canvas';
|
|
6
10
|
import { Toast } from '~/components/toast/toast';
|
|
7
|
-
import { getImageUrl } from '~/components/actions/image-manage';
|
|
11
|
+
import { getImageUrl, fetchFiles, deleteFile } from '~/components/actions/image-manage';
|
|
8
12
|
import { getNotes, saveNotes } from '~/components/actions/notes-manage';
|
|
9
13
|
import { generatePDF } from '~/components/actions/generate-pdf';
|
|
14
|
+
import { CaseExport, type ExportFormat } from '~/components/sidebar/case-export/case-export';
|
|
15
|
+
import { CasesModal } from '~/components/sidebar/cases/cases-modal';
|
|
16
|
+
import { FilesModal } from '~/components/sidebar/files/files-modal';
|
|
17
|
+
import { NotesEditorModal } from '~/components/sidebar/notes/notes-editor-modal';
|
|
18
|
+
import { UserAuditViewer } from '~/components/audit/user-audit-viewer';
|
|
10
19
|
import { fetchUserApi } from '~/utils/api';
|
|
11
20
|
import { resolveEarliestAnnotationTimestamp } from '~/utils/ui';
|
|
12
21
|
import { type AnnotationData, type FileData } from '~/types';
|
|
13
|
-
import
|
|
22
|
+
import type * as CaseExportActions from '~/components/actions/case-export';
|
|
23
|
+
import { checkCaseIsReadOnly, validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
|
|
24
|
+
import { checkReadOnlyCaseExists } from '~/components/actions/case-review';
|
|
25
|
+
import { canCreateCase, getLimitsDescription, getUserData } from '~/utils/data';
|
|
14
26
|
import styles from './striae.module.css';
|
|
15
27
|
|
|
16
28
|
interface StriaePage {
|
|
17
29
|
user: User;
|
|
18
30
|
}
|
|
19
31
|
|
|
32
|
+
type CaseExportActionsModule = typeof CaseExportActions;
|
|
33
|
+
|
|
34
|
+
let caseExportActionsPromise: Promise<CaseExportActionsModule> | null = null;
|
|
35
|
+
|
|
36
|
+
const loadCaseExportActions = (): Promise<CaseExportActionsModule> => {
|
|
37
|
+
if (!caseExportActionsPromise) {
|
|
38
|
+
caseExportActionsPromise = import('~/components/actions/case-export');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return caseExportActionsPromise;
|
|
42
|
+
};
|
|
43
|
+
|
|
20
44
|
export const Striae = ({ user }: StriaePage) => {
|
|
21
45
|
// Image and error states
|
|
22
46
|
const [selectedImage, setSelectedImage] = useState<string>();
|
|
@@ -28,13 +52,14 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
28
52
|
// User states
|
|
29
53
|
const [userCompany, setUserCompany] = useState<string>('');
|
|
30
54
|
const [userFirstName, setUserFirstName] = useState<string>('');
|
|
55
|
+
const [userLastName, setUserLastName] = useState<string>('');
|
|
56
|
+
const [userBadgeId, setUserBadgeId] = useState<string>('');
|
|
31
57
|
|
|
32
58
|
// Case management states - All managed here
|
|
33
59
|
const [currentCase, setCurrentCase] = useState<string>('');
|
|
34
60
|
const [files, setFiles] = useState<FileData[]>([]);
|
|
35
|
-
const [caseNumber, setCaseNumber] = useState('');
|
|
36
|
-
const [successAction, setSuccessAction] = useState<'loaded' | 'created' | 'deleted' | null>(null);
|
|
37
61
|
const [showNotes, setShowNotes] = useState(false);
|
|
62
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
38
63
|
const [isReadOnlyCase, setIsReadOnlyCase] = useState(false);
|
|
39
64
|
|
|
40
65
|
// Annotation states
|
|
@@ -51,7 +76,27 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
51
76
|
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
|
52
77
|
const [showToast, setShowToast] = useState(false);
|
|
53
78
|
const [toastMessage, setToastMessage] = useState('');
|
|
54
|
-
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
|
79
|
+
const [toastType, setToastType] = useState<'success' | 'error' | 'warning'>('success');
|
|
80
|
+
const [isCaseExportModalOpen, setIsCaseExportModalOpen] = useState(false);
|
|
81
|
+
const [isAuditTrailOpen, setIsAuditTrailOpen] = useState(false);
|
|
82
|
+
const [isRenameCaseModalOpen, setIsRenameCaseModalOpen] = useState(false);
|
|
83
|
+
const [isOpenCaseModalOpen, setIsOpenCaseModalOpen] = useState(false);
|
|
84
|
+
const [isListCasesModalOpen, setIsListCasesModalOpen] = useState(false);
|
|
85
|
+
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
|
86
|
+
const [isRenamingCase, setIsRenamingCase] = useState(false);
|
|
87
|
+
const [isDeletingCase, setIsDeletingCase] = useState(false);
|
|
88
|
+
const [isArchivingCase, setIsArchivingCase] = useState(false);
|
|
89
|
+
const [isDeletingFile, setIsDeletingFile] = useState(false);
|
|
90
|
+
const [isOpeningCase, setIsOpeningCase] = useState(false);
|
|
91
|
+
const [openCaseHelperText, setOpenCaseHelperText] = useState('');
|
|
92
|
+
const [isArchiveCaseModalOpen, setIsArchiveCaseModalOpen] = useState(false);
|
|
93
|
+
const [archiveDetails, setArchiveDetails] = useState<{
|
|
94
|
+
archived: boolean;
|
|
95
|
+
archivedAt?: string;
|
|
96
|
+
archivedBy?: string;
|
|
97
|
+
archivedByDisplay?: string;
|
|
98
|
+
archiveReason?: string;
|
|
99
|
+
}>({ archived: false });
|
|
55
100
|
|
|
56
101
|
|
|
57
102
|
useEffect(() => {
|
|
@@ -68,6 +113,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
68
113
|
setActiveAnnotations(new Set());
|
|
69
114
|
setIsBoxAnnotationMode(false);
|
|
70
115
|
setIsReadOnlyCase(false);
|
|
116
|
+
setArchiveDetails({ archived: false });
|
|
71
117
|
}
|
|
72
118
|
}, [currentCase]);
|
|
73
119
|
|
|
@@ -80,9 +126,11 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
80
126
|
});
|
|
81
127
|
|
|
82
128
|
if (response.ok) {
|
|
83
|
-
const userData = await response.json() as { company?: string; firstName?: string };
|
|
129
|
+
const userData = await response.json() as { company?: string; firstName?: string; lastName?: string; badgeId?: string };
|
|
84
130
|
setUserCompany(userData.company || '');
|
|
85
131
|
setUserFirstName(userData.firstName || '');
|
|
132
|
+
setUserLastName(userData.lastName || '');
|
|
133
|
+
setUserBadgeId(userData.badgeId || '');
|
|
86
134
|
}
|
|
87
135
|
} catch (err) {
|
|
88
136
|
console.error('Failed to load user company:', err);
|
|
@@ -96,7 +144,6 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
96
144
|
|
|
97
145
|
const handleCaseChange = (caseNumber: string) => {
|
|
98
146
|
setCurrentCase(caseNumber);
|
|
99
|
-
setCaseNumber(caseNumber);
|
|
100
147
|
setAnnotationData(null);
|
|
101
148
|
setSelectedFilename(undefined);
|
|
102
149
|
setImageId(undefined);
|
|
@@ -114,9 +161,12 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
114
161
|
// Check if the case data itself has isReadOnly: true
|
|
115
162
|
const isReadOnly = await checkCaseIsReadOnly(user, currentCase);
|
|
116
163
|
setIsReadOnlyCase(isReadOnly);
|
|
164
|
+
const details = await getCaseArchiveDetails(user, currentCase);
|
|
165
|
+
setArchiveDetails(details);
|
|
117
166
|
} catch (error) {
|
|
118
167
|
console.error('Error checking read-only status:', error);
|
|
119
168
|
setIsReadOnlyCase(false);
|
|
169
|
+
setArchiveDetails({ archived: false });
|
|
120
170
|
}
|
|
121
171
|
};
|
|
122
172
|
|
|
@@ -167,6 +217,8 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
167
217
|
selectedFilename,
|
|
168
218
|
userCompany,
|
|
169
219
|
userFirstName,
|
|
220
|
+
userLastName,
|
|
221
|
+
userBadgeId,
|
|
170
222
|
currentCase,
|
|
171
223
|
annotationData,
|
|
172
224
|
activeAnnotations,
|
|
@@ -177,16 +229,309 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
177
229
|
});
|
|
178
230
|
};
|
|
179
231
|
|
|
232
|
+
const showNotification = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
|
233
|
+
setToastType(type);
|
|
234
|
+
setToastMessage(message);
|
|
235
|
+
setShowToast(true);
|
|
236
|
+
};
|
|
237
|
+
|
|
180
238
|
// Close toast notification
|
|
181
239
|
const closeToast = () => {
|
|
182
240
|
setShowToast(false);
|
|
183
241
|
};
|
|
184
242
|
|
|
243
|
+
const handleExport = async (
|
|
244
|
+
exportCaseNumber: string,
|
|
245
|
+
format: ExportFormat,
|
|
246
|
+
includeImages?: boolean,
|
|
247
|
+
onProgress?: (progress: number, label: string) => void
|
|
248
|
+
) => {
|
|
249
|
+
const caseExportActions = await loadCaseExportActions();
|
|
250
|
+
|
|
251
|
+
if (includeImages) {
|
|
252
|
+
await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format, (progress) => {
|
|
253
|
+
const label = progress < 30 ? 'Loading case data'
|
|
254
|
+
: progress < 50 ? 'Preparing archive'
|
|
255
|
+
: progress < 80 ? 'Adding images'
|
|
256
|
+
: progress < 96 ? 'Finalizing'
|
|
257
|
+
: 'Downloading';
|
|
258
|
+
onProgress?.(Math.round(progress), label);
|
|
259
|
+
});
|
|
260
|
+
showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
onProgress?.(5, 'Loading case data');
|
|
265
|
+
const exportData = await caseExportActions.exportCaseData(
|
|
266
|
+
user,
|
|
267
|
+
exportCaseNumber,
|
|
268
|
+
{ includeMetadata: true },
|
|
269
|
+
(current, total, label) => {
|
|
270
|
+
const progress = total > 0 ? Math.round(10 + (current / total) * 60) : 10;
|
|
271
|
+
onProgress?.(progress, label);
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
onProgress?.(75, 'Preparing download');
|
|
276
|
+
if (format === 'json') {
|
|
277
|
+
await caseExportActions.downloadCaseAsJSON(user, exportData);
|
|
278
|
+
} else {
|
|
279
|
+
await caseExportActions.downloadCaseAsCSV(user, exportData);
|
|
280
|
+
}
|
|
281
|
+
onProgress?.(100, 'Complete');
|
|
282
|
+
showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const handleExportAll = async (
|
|
286
|
+
onProgress: (current: number, total: number, caseName: string) => void,
|
|
287
|
+
format: ExportFormat
|
|
288
|
+
) => {
|
|
289
|
+
const caseExportActions = await loadCaseExportActions();
|
|
290
|
+
const exportData = await caseExportActions.exportAllCases(
|
|
291
|
+
user,
|
|
292
|
+
{ includeMetadata: true },
|
|
293
|
+
onProgress
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
if (format === 'json') {
|
|
297
|
+
await caseExportActions.downloadAllCasesAsJSON(user, exportData);
|
|
298
|
+
} else {
|
|
299
|
+
await caseExportActions.downloadAllCasesAsCSV(user, exportData);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
showNotification('All cases exported successfully.', 'success');
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const handleRenameCaseSubmit = async (newCaseName: string) => {
|
|
306
|
+
if (!currentCase) {
|
|
307
|
+
showNotification('Select a case before renaming.', 'error');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!validateCaseNumber(newCaseName)) {
|
|
312
|
+
showNotification('Invalid case number format.', 'error');
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
setIsRenamingCase(true);
|
|
317
|
+
try {
|
|
318
|
+
const existingReadOnlyCase = await checkReadOnlyCaseExists(user, newCaseName);
|
|
319
|
+
if (existingReadOnlyCase) {
|
|
320
|
+
showNotification(`Case "${newCaseName}" already exists as a read-only review case.`, 'error');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await renameCase(user, currentCase, newCaseName);
|
|
325
|
+
setCurrentCase(newCaseName);
|
|
326
|
+
setShowNotes(false);
|
|
327
|
+
setIsRenameCaseModalOpen(false);
|
|
328
|
+
showNotification(`Case renamed to ${newCaseName}.`, 'success');
|
|
329
|
+
} catch (renameError) {
|
|
330
|
+
showNotification(renameError instanceof Error ? renameError.message : 'Failed to rename case.', 'error');
|
|
331
|
+
} finally {
|
|
332
|
+
setIsRenamingCase(false);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const handleDeleteCaseAction = async () => {
|
|
337
|
+
if (!currentCase) {
|
|
338
|
+
showNotification('Select a case before deleting.', 'error');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const confirmed = window.confirm(
|
|
343
|
+
`Are you sure you want to delete case ${currentCase}? This will permanently delete all associated files and cannot be undone. If any image assets are already missing (404), they will be skipped and the case deletion will continue.`
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (!confirmed) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
setIsDeletingCase(true);
|
|
351
|
+
try {
|
|
352
|
+
const deleteResult = await deleteCase(user, currentCase);
|
|
353
|
+
setCurrentCase('');
|
|
354
|
+
setFiles([]);
|
|
355
|
+
setShowNotes(false);
|
|
356
|
+
setIsAuditTrailOpen(false);
|
|
357
|
+
setIsRenameCaseModalOpen(false);
|
|
358
|
+
if (deleteResult.missingImages.length > 0) {
|
|
359
|
+
showNotification(
|
|
360
|
+
`Case deleted. ${deleteResult.missingImages.length} image(s) were not found and were skipped during deletion.`,
|
|
361
|
+
'warning'
|
|
362
|
+
);
|
|
363
|
+
} else {
|
|
364
|
+
showNotification('Case deleted successfully.', 'success');
|
|
365
|
+
}
|
|
366
|
+
} catch (deleteError) {
|
|
367
|
+
showNotification(deleteError instanceof Error ? deleteError.message : 'Failed to delete case.', 'error');
|
|
368
|
+
} finally {
|
|
369
|
+
setIsDeletingCase(false);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const handleDeleteCurrentFileAction = async () => {
|
|
374
|
+
if (!currentCase || !imageId) {
|
|
375
|
+
showNotification('Load an image before deleting a file.', 'error');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (isReadOnlyCase) {
|
|
380
|
+
showNotification('Cannot delete files for read-only cases.', 'error');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const selectedFile = files.find((file) => file.id === imageId);
|
|
385
|
+
const selectedFileName = selectedFile?.originalFilename || imageId;
|
|
386
|
+
const confirmed = window.confirm(
|
|
387
|
+
`Are you sure you want to delete ${selectedFileName}? This action cannot be undone.`
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
if (!confirmed) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
setIsDeletingFile(true);
|
|
395
|
+
try {
|
|
396
|
+
const deleteResult = await deleteFile(user, currentCase, imageId, 'User-requested deletion via navbar file management');
|
|
397
|
+
const updatedFiles = files.filter((file) => file.id !== imageId);
|
|
398
|
+
setFiles(updatedFiles);
|
|
399
|
+
handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
|
|
400
|
+
setShowNotes(false);
|
|
401
|
+
if (deleteResult.imageMissing) {
|
|
402
|
+
showNotification(
|
|
403
|
+
`File record deleted. Image asset "${deleteResult.fileName}" was not found and was skipped.`,
|
|
404
|
+
'warning'
|
|
405
|
+
);
|
|
406
|
+
} else {
|
|
407
|
+
showNotification('File deleted successfully.', 'success');
|
|
408
|
+
}
|
|
409
|
+
} catch (deleteError) {
|
|
410
|
+
showNotification(deleteError instanceof Error ? deleteError.message : 'Failed to delete file.', 'error');
|
|
411
|
+
} finally {
|
|
412
|
+
setIsDeletingFile(false);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const handleArchiveCaseSubmit = async (archiveReason: string) => {
|
|
417
|
+
if (!currentCase) {
|
|
418
|
+
showNotification('Select a case before archiving.', 'error');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (isReadOnlyCase) {
|
|
423
|
+
showNotification('This case is already read-only and cannot be archived again.', 'error');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
setIsArchivingCase(true);
|
|
428
|
+
try {
|
|
429
|
+
await archiveCase(user, currentCase, archiveReason);
|
|
430
|
+
setIsReadOnlyCase(true);
|
|
431
|
+
setArchiveDetails({
|
|
432
|
+
archived: true,
|
|
433
|
+
archivedAt: new Date().toISOString(),
|
|
434
|
+
archivedBy: user.uid,
|
|
435
|
+
archivedByDisplay: [
|
|
436
|
+
[userFirstName.trim(), userLastName.trim()].filter(Boolean).join(' ').trim(),
|
|
437
|
+
userBadgeId.trim(),
|
|
438
|
+
].filter(Boolean).join(', ') || user.uid,
|
|
439
|
+
archiveReason: archiveReason.trim() || undefined,
|
|
440
|
+
});
|
|
441
|
+
setShowNotes(false);
|
|
442
|
+
setIsArchiveCaseModalOpen(false);
|
|
443
|
+
showNotification('Case archived successfully. The archive package download has started.', 'success');
|
|
444
|
+
} catch (archiveError) {
|
|
445
|
+
showNotification(archiveError instanceof Error ? archiveError.message : 'Failed to archive case.', 'error');
|
|
446
|
+
} finally {
|
|
447
|
+
setIsArchivingCase(false);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const loadCaseIntoWorkspace = async (caseToLoad: string) => {
|
|
452
|
+
setCurrentCase(caseToLoad);
|
|
453
|
+
setShowNotes(false);
|
|
454
|
+
const loadedFiles = await fetchFiles(user, caseToLoad, { skipValidation: true });
|
|
455
|
+
setFiles(loadedFiles);
|
|
456
|
+
showNotification(`Case ${caseToLoad} loaded successfully.`, 'success');
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const handleOpenCaseSubmit = async (nextCaseNumber: string) => {
|
|
460
|
+
if (!validateCaseNumber(nextCaseNumber)) {
|
|
461
|
+
showNotification('Invalid case number format.', 'error');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
setIsOpeningCase(true);
|
|
466
|
+
try {
|
|
467
|
+
const existingCase = await checkExistingCase(user, nextCaseNumber);
|
|
468
|
+
if (existingCase) {
|
|
469
|
+
await loadCaseIntoWorkspace(nextCaseNumber);
|
|
470
|
+
setIsOpenCaseModalOpen(false);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const existingReadOnlyCase = await checkReadOnlyCaseExists(user, nextCaseNumber);
|
|
475
|
+
if (existingReadOnlyCase) {
|
|
476
|
+
showNotification(`Case "${nextCaseNumber}" already exists as a read-only review case.`, 'error');
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const permission = await canCreateCase(user);
|
|
481
|
+
if (!permission.canCreate) {
|
|
482
|
+
showNotification(permission.reason || 'You cannot create more cases.', 'error');
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const newCase = await createNewCase(user, nextCaseNumber);
|
|
487
|
+
setCurrentCase(newCase.caseNumber);
|
|
488
|
+
setFiles([]);
|
|
489
|
+
setShowNotes(false);
|
|
490
|
+
setIsOpenCaseModalOpen(false);
|
|
491
|
+
showNotification(`Case ${newCase.caseNumber} created successfully.`, 'success');
|
|
492
|
+
} catch (openCaseError) {
|
|
493
|
+
showNotification(openCaseError instanceof Error ? openCaseError.message : 'Failed to load/create case.', 'error');
|
|
494
|
+
} finally {
|
|
495
|
+
setIsOpeningCase(false);
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const handleOpenCaseModal = async () => {
|
|
500
|
+
setIsOpenCaseModalOpen(true);
|
|
501
|
+
try {
|
|
502
|
+
const userData = await getUserData(user);
|
|
503
|
+
if (userData && !userData.permitted) {
|
|
504
|
+
const limitsDescription = await getLimitsDescription(user);
|
|
505
|
+
setOpenCaseHelperText(limitsDescription || 'Load an existing case or create a new one.');
|
|
506
|
+
} else {
|
|
507
|
+
setOpenCaseHelperText('Load an existing case or create a new one.');
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
setOpenCaseHelperText('Load an existing case or create a new one.');
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
185
514
|
// Function to refresh annotation data (called when notes are saved)
|
|
186
515
|
const refreshAnnotationData = () => {
|
|
187
516
|
setAnnotationRefreshTrigger(prev => prev + 1);
|
|
188
517
|
};
|
|
189
518
|
|
|
519
|
+
// Handle import/clear read-only case
|
|
520
|
+
const handleImportComplete = (result: { success: boolean; caseNumber?: string; isReadOnly?: boolean }) => {
|
|
521
|
+
if (result.success) {
|
|
522
|
+
if (result.caseNumber && result.isReadOnly) {
|
|
523
|
+
// Successful read-only case import - load the case
|
|
524
|
+
handleCaseChange(result.caseNumber);
|
|
525
|
+
} else if (!result.caseNumber && !result.isReadOnly) {
|
|
526
|
+
// Read-only case cleared - reset all UI state
|
|
527
|
+
setCurrentCase('');
|
|
528
|
+
setFiles([]);
|
|
529
|
+
handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
|
|
530
|
+
setShowNotes(false);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
190
535
|
useEffect(() => {
|
|
191
536
|
// Cleanup function to clear image when component unmounts
|
|
192
537
|
return () => {
|
|
@@ -281,6 +626,15 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
281
626
|
}
|
|
282
627
|
};
|
|
283
628
|
|
|
629
|
+
const hasLoadedImage = !!(selectedImage && selectedImage !== '/clear.jpg' && imageLoaded);
|
|
630
|
+
const isCurrentImageConfirmed = hasLoadedImage && !!annotationData?.confirmationData;
|
|
631
|
+
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
if (showNotes && (!hasLoadedImage || isCurrentImageConfirmed)) {
|
|
634
|
+
setShowNotes(false);
|
|
635
|
+
}
|
|
636
|
+
}, [showNotes, hasLoadedImage, isCurrentImageConfirmed]);
|
|
637
|
+
|
|
284
638
|
// Automatic save handler for annotation updates
|
|
285
639
|
const handleAnnotationUpdate = async (data: AnnotationData) => {
|
|
286
640
|
if (annotationData?.confirmationData) {
|
|
@@ -346,31 +700,54 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
346
700
|
|
|
347
701
|
return (
|
|
348
702
|
<div className={styles.appContainer}>
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
imageId={imageId}
|
|
353
|
-
onCaseChange={handleCaseChange}
|
|
354
|
-
currentCase={currentCase}
|
|
355
|
-
setCurrentCase={setCurrentCase}
|
|
356
|
-
imageLoaded={imageLoaded}
|
|
357
|
-
setImageLoaded={setImageLoaded}
|
|
358
|
-
files={files}
|
|
359
|
-
setFiles={setFiles}
|
|
360
|
-
caseNumber={caseNumber}
|
|
361
|
-
setCaseNumber={setCaseNumber}
|
|
362
|
-
error={error ?? ''}
|
|
363
|
-
setError={setError}
|
|
364
|
-
successAction={successAction}
|
|
365
|
-
setSuccessAction={setSuccessAction}
|
|
366
|
-
showNotes={showNotes}
|
|
367
|
-
setShowNotes={setShowNotes}
|
|
368
|
-
onAnnotationRefresh={refreshAnnotationData}
|
|
703
|
+
<Navbar
|
|
704
|
+
isUploading={isUploading}
|
|
705
|
+
company={userCompany}
|
|
369
706
|
isReadOnly={isReadOnlyCase}
|
|
370
|
-
|
|
371
|
-
|
|
707
|
+
currentCase={currentCase}
|
|
708
|
+
currentFileName={selectedFilename}
|
|
709
|
+
isCurrentImageConfirmed={isCurrentImageConfirmed}
|
|
710
|
+
hasLoadedCase={!!currentCase}
|
|
711
|
+
hasLoadedImage={hasLoadedImage}
|
|
712
|
+
archiveDetails={archiveDetails}
|
|
713
|
+
onImportComplete={handleImportComplete}
|
|
714
|
+
onOpenCase={() => {
|
|
715
|
+
void handleOpenCaseModal();
|
|
716
|
+
}}
|
|
717
|
+
onOpenListAllCases={() => setIsListCasesModalOpen(true)}
|
|
718
|
+
onOpenCaseExport={() => setIsCaseExportModalOpen(true)}
|
|
719
|
+
onOpenAuditTrail={() => setIsAuditTrailOpen(true)}
|
|
720
|
+
onOpenRenameCase={() => setIsRenameCaseModalOpen(true)}
|
|
721
|
+
onDeleteCase={() => {
|
|
722
|
+
void handleDeleteCaseAction();
|
|
723
|
+
}}
|
|
724
|
+
onArchiveCase={() => setIsArchiveCaseModalOpen(true)}
|
|
725
|
+
onOpenViewAllFiles={() => setIsFilesModalOpen(true)}
|
|
726
|
+
onDeleteCurrentFile={() => {
|
|
727
|
+
void handleDeleteCurrentFileAction();
|
|
728
|
+
}}
|
|
729
|
+
onOpenImageNotes={() => setShowNotes(true)}
|
|
372
730
|
/>
|
|
373
|
-
<
|
|
731
|
+
<div className={styles.contentRow}>
|
|
732
|
+
<SidebarContainer
|
|
733
|
+
user={user}
|
|
734
|
+
onImageSelect={handleImageSelect}
|
|
735
|
+
imageId={imageId}
|
|
736
|
+
currentCase={currentCase}
|
|
737
|
+
imageLoaded={imageLoaded}
|
|
738
|
+
setImageLoaded={setImageLoaded}
|
|
739
|
+
files={files}
|
|
740
|
+
setFiles={setFiles}
|
|
741
|
+
showNotes={showNotes}
|
|
742
|
+
setShowNotes={setShowNotes}
|
|
743
|
+
onAnnotationRefresh={refreshAnnotationData}
|
|
744
|
+
isReadOnly={isReadOnlyCase}
|
|
745
|
+
isConfirmed={!!annotationData?.confirmationData}
|
|
746
|
+
confirmationSaveVersion={confirmationSaveVersion}
|
|
747
|
+
isUploading={isUploading}
|
|
748
|
+
onUploadStatusChange={setIsUploading}
|
|
749
|
+
/>
|
|
750
|
+
<main className={styles.mainContent}>
|
|
374
751
|
<div className={styles.canvasArea}>
|
|
375
752
|
<div className={styles.toolbarWrapper}>
|
|
376
753
|
<Toolbar
|
|
@@ -389,6 +766,7 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
389
766
|
imageUrl={selectedImage}
|
|
390
767
|
filename={selectedFilename}
|
|
391
768
|
company={userCompany}
|
|
769
|
+
badgeId={userBadgeId}
|
|
392
770
|
firstName={userFirstName}
|
|
393
771
|
error={error ?? ''}
|
|
394
772
|
activeAnnotations={activeAnnotations}
|
|
@@ -397,11 +775,79 @@ export const Striae = ({ user }: StriaePage) => {
|
|
|
397
775
|
boxAnnotationColor={boxAnnotationColor}
|
|
398
776
|
onAnnotationUpdate={handleAnnotationUpdate}
|
|
399
777
|
isReadOnly={isReadOnlyCase}
|
|
778
|
+
isArchivedCase={archiveDetails.archived}
|
|
400
779
|
caseNumber={currentCase}
|
|
401
780
|
currentImageId={imageId}
|
|
402
781
|
/>
|
|
403
782
|
</div>
|
|
404
|
-
|
|
783
|
+
</main>
|
|
784
|
+
</div>
|
|
785
|
+
<OpenCaseModal
|
|
786
|
+
isOpen={isOpenCaseModalOpen}
|
|
787
|
+
isSubmitting={isOpeningCase}
|
|
788
|
+
helperText={openCaseHelperText}
|
|
789
|
+
onClose={() => setIsOpenCaseModalOpen(false)}
|
|
790
|
+
onSubmit={handleOpenCaseSubmit}
|
|
791
|
+
/>
|
|
792
|
+
<CasesModal
|
|
793
|
+
isOpen={isListCasesModalOpen}
|
|
794
|
+
onClose={() => setIsListCasesModalOpen(false)}
|
|
795
|
+
onSelectCase={(selectedCase) => {
|
|
796
|
+
void loadCaseIntoWorkspace(selectedCase);
|
|
797
|
+
}}
|
|
798
|
+
currentCase={currentCase || ''}
|
|
799
|
+
user={user}
|
|
800
|
+
/>
|
|
801
|
+
<FilesModal
|
|
802
|
+
isOpen={isFilesModalOpen}
|
|
803
|
+
onClose={() => setIsFilesModalOpen(false)}
|
|
804
|
+
onFileSelect={(file) => {
|
|
805
|
+
void handleImageSelect(file);
|
|
806
|
+
}}
|
|
807
|
+
currentCase={currentCase || null}
|
|
808
|
+
files={files}
|
|
809
|
+
setFiles={setFiles}
|
|
810
|
+
isReadOnly={isReadOnlyCase}
|
|
811
|
+
selectedFileId={imageId}
|
|
812
|
+
/>
|
|
813
|
+
<NotesEditorModal
|
|
814
|
+
isOpen={showNotes}
|
|
815
|
+
onClose={() => setShowNotes(false)}
|
|
816
|
+
currentCase={currentCase}
|
|
817
|
+
user={user}
|
|
818
|
+
imageId={imageId || ''}
|
|
819
|
+
onAnnotationRefresh={refreshAnnotationData}
|
|
820
|
+
originalFileName={files.find(file => file.id === imageId)?.originalFilename}
|
|
821
|
+
isUploading={isUploading}
|
|
822
|
+
/>
|
|
823
|
+
<CaseExport
|
|
824
|
+
isOpen={isCaseExportModalOpen}
|
|
825
|
+
onClose={() => setIsCaseExportModalOpen(false)}
|
|
826
|
+
onExport={handleExport}
|
|
827
|
+
onExportAll={handleExportAll}
|
|
828
|
+
currentCaseNumber={currentCase}
|
|
829
|
+
isReadOnly={isReadOnlyCase}
|
|
830
|
+
/>
|
|
831
|
+
<UserAuditViewer
|
|
832
|
+
caseNumber={currentCase || ''}
|
|
833
|
+
isOpen={isAuditTrailOpen}
|
|
834
|
+
onClose={() => setIsAuditTrailOpen(false)}
|
|
835
|
+
title={`Audit Trail - Case ${currentCase}`}
|
|
836
|
+
/>
|
|
837
|
+
<RenameCaseModal
|
|
838
|
+
isOpen={isRenameCaseModalOpen}
|
|
839
|
+
currentCase={currentCase}
|
|
840
|
+
isSubmitting={isRenamingCase || isDeletingCase || isDeletingFile || isArchivingCase}
|
|
841
|
+
onClose={() => setIsRenameCaseModalOpen(false)}
|
|
842
|
+
onSubmit={handleRenameCaseSubmit}
|
|
843
|
+
/>
|
|
844
|
+
<ArchiveCaseModal
|
|
845
|
+
isOpen={isArchiveCaseModalOpen}
|
|
846
|
+
currentCase={currentCase}
|
|
847
|
+
isSubmitting={isArchivingCase}
|
|
848
|
+
onClose={() => setIsArchiveCaseModalOpen(false)}
|
|
849
|
+
onSubmit={handleArchiveCaseSubmit}
|
|
850
|
+
/>
|
|
405
851
|
<Toast
|
|
406
852
|
message={toastMessage}
|
|
407
853
|
type={toastType}
|
package/app/routes.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
|
2
|
+
|
|
3
|
+
export default [
|
|
4
|
+
index("routes/_index.tsx"),
|
|
5
|
+
route("auth", "routes/auth/route.ts"),
|
|
6
|
+
route("auth/login", "routes/auth/route.ts", { id: "routes/auth/login-alias" }),
|
|
7
|
+
] satisfies RouteConfig;
|
|
@@ -30,6 +30,7 @@ export const AUDIT_CSV_ENTRY_HEADERS = [
|
|
|
30
30
|
'Profile Field',
|
|
31
31
|
'Old Value',
|
|
32
32
|
'New Value',
|
|
33
|
+
'Badge/ID',
|
|
33
34
|
'Total Confirmations In File',
|
|
34
35
|
'Confirmations Successfully Imported',
|
|
35
36
|
'Validation Steps Failed',
|
|
@@ -112,6 +113,7 @@ export const entryToCSVRow = (entry: ValidationAuditEntry): string => {
|
|
|
112
113
|
formatForCSV(userProfileDetails?.profileField),
|
|
113
114
|
formatForCSV(userProfileDetails?.oldValue),
|
|
114
115
|
formatForCSV(userProfileDetails?.newValue),
|
|
116
|
+
formatForCSV(userProfileDetails?.badgeId),
|
|
115
117
|
caseDetails?.totalAnnotations?.toString() || '',
|
|
116
118
|
performanceMetrics?.validationStepsCompleted?.toString() || '',
|
|
117
119
|
performanceMetrics?.validationStepsFailed?.toString() || '',
|