@striae-org/striae 4.1.0 → 4.2.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.
Files changed (124) hide show
  1. package/.env.example +8 -0
  2. package/LICENSE +1 -1
  3. package/app/components/actions/case-export/core-export.ts +14 -8
  4. package/app/components/actions/case-export/data-processing.ts +1 -0
  5. package/app/components/actions/case-export/download-handlers.ts +7 -0
  6. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  7. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  8. package/app/components/actions/case-import/orchestrator.ts +78 -32
  9. package/app/components/actions/case-import/storage-operations.ts +97 -8
  10. package/app/components/actions/case-import/zip-processing.ts +159 -86
  11. package/app/components/actions/case-manage.ts +463 -8
  12. package/app/components/actions/confirm-export.ts +9 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +19 -8
  15. package/app/components/audit/user-audit.module.css +21 -0
  16. package/app/components/audit/viewer/audit-entries-list.tsx +12 -2
  17. package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
  18. package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
  19. package/app/components/audit/viewer/use-audit-viewer-data.ts +24 -1
  20. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  21. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  22. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  23. package/app/components/canvas/canvas.module.css +64 -54
  24. package/app/components/canvas/canvas.tsx +14 -16
  25. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  26. package/app/components/canvas/confirmation/confirmation.tsx +12 -14
  27. package/app/components/colors/colors.module.css +4 -3
  28. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  29. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  30. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  31. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  32. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  33. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  34. package/app/components/navbar/navbar.module.css +447 -0
  35. package/app/components/navbar/navbar.tsx +402 -0
  36. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  37. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
  38. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  39. package/app/components/sidebar/case-export/case-export.tsx +8 -46
  40. package/app/components/sidebar/case-import/case-import.module.css +23 -0
  41. package/app/components/sidebar/case-import/case-import.tsx +64 -16
  42. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  43. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  44. package/app/components/sidebar/cases/case-sidebar.tsx +68 -588
  45. package/app/components/sidebar/cases/cases-modal.module.css +1 -0
  46. package/app/components/sidebar/cases/cases-modal.tsx +82 -43
  47. package/app/components/sidebar/cases/cases.module.css +82 -21
  48. package/app/components/sidebar/files/files-modal.module.css +1 -0
  49. package/app/components/sidebar/files/files-modal.tsx +49 -52
  50. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  51. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +187 -138
  52. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  53. package/app/components/sidebar/notes/notes-editor-modal.tsx +64 -0
  54. package/app/components/sidebar/notes/notes.module.css +170 -1
  55. package/app/components/sidebar/sidebar-container.tsx +16 -28
  56. package/app/components/sidebar/sidebar.module.css +5 -69
  57. package/app/components/sidebar/sidebar.tsx +27 -125
  58. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  59. package/app/components/user/inactivity-warning.module.css +1 -0
  60. package/app/components/user/inactivity-warning.tsx +15 -2
  61. package/app/components/user/manage-profile.tsx +23 -10
  62. package/app/{tailwind.css → global.css} +1 -3
  63. package/app/hooks/useOverlayDismiss.ts +54 -4
  64. package/app/root.tsx +1 -1
  65. package/app/routes/auth/login.tsx +785 -774
  66. package/app/routes/striae/striae.module.css +10 -3
  67. package/app/routes/striae/striae.tsx +475 -30
  68. package/app/services/audit/audit.service.ts +173 -27
  69. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  70. package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
  71. package/app/services/audit/builders/index.ts +1 -0
  72. package/app/types/audit.ts +4 -1
  73. package/app/types/case.ts +29 -0
  74. package/app/types/import.ts +3 -0
  75. package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
  76. package/app/utils/data/data-operations.ts +17 -861
  77. package/app/utils/data/index.ts +11 -1
  78. package/app/utils/data/operations/batch-operations.ts +113 -0
  79. package/app/utils/data/operations/case-operations.ts +168 -0
  80. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  81. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  82. package/app/utils/data/operations/index.ts +7 -0
  83. package/app/utils/data/operations/signing-operations.ts +225 -0
  84. package/app/utils/data/operations/types.ts +42 -0
  85. package/app/utils/data/operations/validation-operations.ts +48 -0
  86. package/app/utils/data/permissions.ts +16 -1
  87. package/app/utils/forensics/audit-export-signature.ts +5 -1
  88. package/app/utils/forensics/confirmation-signature.ts +3 -0
  89. package/app/utils/forensics/export-verification.ts +426 -22
  90. package/functions/api/_shared/firebase-auth.ts +2 -7
  91. package/functions/api/image/[[path]].ts +20 -23
  92. package/functions/api/pdf/[[path]].ts +27 -8
  93. package/package.json +7 -12
  94. package/scripts/deploy-primershear-emails.sh +2 -1
  95. package/worker-configuration.d.ts +3 -3
  96. package/workers/audit-worker/package.json +1 -1
  97. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  98. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  99. package/workers/data-worker/package.json +1 -1
  100. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  101. package/workers/data-worker/wrangler.jsonc.example +1 -1
  102. package/workers/image-worker/package.json +1 -1
  103. package/workers/image-worker/src/image-worker.example.ts +16 -5
  104. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  105. package/workers/image-worker/wrangler.jsonc.example +1 -1
  106. package/workers/keys-worker/package.json +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/package.json +1 -1
  110. package/workers/pdf-worker/src/formats/format-striae.ts +9 -14
  111. package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
  112. package/workers/pdf-worker/src/report-types.ts +3 -3
  113. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  114. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  115. package/workers/user-worker/package.json +1 -1
  116. package/workers/user-worker/src/user-worker.example.ts +17 -0
  117. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  118. package/workers/user-worker/wrangler.jsonc.example +1 -1
  119. package/wrangler.toml.example +1 -1
  120. package/NOTICE +0 -13
  121. package/app/components/sidebar/notes/notes-modal.tsx +0 -53
  122. package/postcss.config.js +0 -6
  123. package/public/.well-known/keybase.txt +0 -56
  124. package/tailwind.config.ts +0 -22
@@ -1,7 +1,8 @@
1
1
  .appContainer {
2
2
  display: flex;
3
- width: 100vw;
4
- height: 100vh;
3
+ flex-direction: column;
4
+ width: 100%;
5
+ height: 100vh;
5
6
  overflow: hidden;
6
7
  position: fixed;
7
8
  top: 0;
@@ -9,6 +10,12 @@
9
10
  z-index: 10;
10
11
  }
11
12
 
13
+ .contentRow {
14
+ display: flex;
15
+ flex: 1;
16
+ min-height: 0;
17
+ }
18
+
12
19
  .mainContent {
13
20
  flex: 1;
14
21
  display: flex;
@@ -27,4 +34,4 @@
27
34
  top: var(--spaceL);
28
35
  left: var(--spaceL);
29
36
  z-index: 30;
30
- }
37
+ }
@@ -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 { checkCaseIsReadOnly } from '~/components/actions/case-manage';
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>();
@@ -34,9 +58,8 @@ export const Striae = ({ user }: StriaePage) => {
34
58
  // Case management states - All managed here
35
59
  const [currentCase, setCurrentCase] = useState<string>('');
36
60
  const [files, setFiles] = useState<FileData[]>([]);
37
- const [caseNumber, setCaseNumber] = useState('');
38
- const [successAction, setSuccessAction] = useState<'loaded' | 'created' | 'deleted' | null>(null);
39
61
  const [showNotes, setShowNotes] = useState(false);
62
+ const [isUploading, setIsUploading] = useState(false);
40
63
  const [isReadOnlyCase, setIsReadOnlyCase] = useState(false);
41
64
 
42
65
  // Annotation states
@@ -53,7 +76,27 @@ export const Striae = ({ user }: StriaePage) => {
53
76
  const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
54
77
  const [showToast, setShowToast] = useState(false);
55
78
  const [toastMessage, setToastMessage] = useState('');
56
- 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 });
57
100
 
58
101
 
59
102
  useEffect(() => {
@@ -70,6 +113,7 @@ export const Striae = ({ user }: StriaePage) => {
70
113
  setActiveAnnotations(new Set());
71
114
  setIsBoxAnnotationMode(false);
72
115
  setIsReadOnlyCase(false);
116
+ setArchiveDetails({ archived: false });
73
117
  }
74
118
  }, [currentCase]);
75
119
 
@@ -100,7 +144,6 @@ export const Striae = ({ user }: StriaePage) => {
100
144
 
101
145
  const handleCaseChange = (caseNumber: string) => {
102
146
  setCurrentCase(caseNumber);
103
- setCaseNumber(caseNumber);
104
147
  setAnnotationData(null);
105
148
  setSelectedFilename(undefined);
106
149
  setImageId(undefined);
@@ -118,9 +161,12 @@ export const Striae = ({ user }: StriaePage) => {
118
161
  // Check if the case data itself has isReadOnly: true
119
162
  const isReadOnly = await checkCaseIsReadOnly(user, currentCase);
120
163
  setIsReadOnlyCase(isReadOnly);
164
+ const details = await getCaseArchiveDetails(user, currentCase);
165
+ setArchiveDetails(details);
121
166
  } catch (error) {
122
167
  console.error('Error checking read-only status:', error);
123
168
  setIsReadOnlyCase(false);
169
+ setArchiveDetails({ archived: false });
124
170
  }
125
171
  };
126
172
 
@@ -183,16 +229,309 @@ export const Striae = ({ user }: StriaePage) => {
183
229
  });
184
230
  };
185
231
 
232
+ const showNotification = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
233
+ setToastType(type);
234
+ setToastMessage(message);
235
+ setShowToast(true);
236
+ };
237
+
186
238
  // Close toast notification
187
239
  const closeToast = () => {
188
240
  setShowToast(false);
189
241
  };
190
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
+
191
514
  // Function to refresh annotation data (called when notes are saved)
192
515
  const refreshAnnotationData = () => {
193
516
  setAnnotationRefreshTrigger(prev => prev + 1);
194
517
  };
195
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
+
196
535
  useEffect(() => {
197
536
  // Cleanup function to clear image when component unmounts
198
537
  return () => {
@@ -287,6 +626,15 @@ export const Striae = ({ user }: StriaePage) => {
287
626
  }
288
627
  };
289
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
+
290
638
  // Automatic save handler for annotation updates
291
639
  const handleAnnotationUpdate = async (data: AnnotationData) => {
292
640
  if (annotationData?.confirmationData) {
@@ -352,31 +700,57 @@ export const Striae = ({ user }: StriaePage) => {
352
700
 
353
701
  return (
354
702
  <div className={styles.appContainer}>
355
- <SidebarContainer
356
- user={user}
357
- onImageSelect={handleImageSelect}
358
- imageId={imageId}
359
- onCaseChange={handleCaseChange}
360
- currentCase={currentCase}
361
- setCurrentCase={setCurrentCase}
362
- imageLoaded={imageLoaded}
363
- setImageLoaded={setImageLoaded}
364
- files={files}
365
- setFiles={setFiles}
366
- caseNumber={caseNumber}
367
- setCaseNumber={setCaseNumber}
368
- error={error ?? ''}
369
- setError={setError}
370
- successAction={successAction}
371
- setSuccessAction={setSuccessAction}
372
- showNotes={showNotes}
373
- setShowNotes={setShowNotes}
374
- onAnnotationRefresh={refreshAnnotationData}
703
+ <Navbar
704
+ isUploading={isUploading}
705
+ company={userCompany}
375
706
  isReadOnly={isReadOnlyCase}
376
- isConfirmed={!!annotationData?.confirmationData}
377
- confirmationSaveVersion={confirmationSaveVersion}
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)}
378
730
  />
379
- <main className={styles.mainContent}>
731
+ <div className={styles.contentRow}>
732
+ <SidebarContainer
733
+ user={user}
734
+ onImageSelect={handleImageSelect}
735
+ onOpenCase={() => {
736
+ void handleOpenCaseModal();
737
+ }}
738
+ imageId={imageId}
739
+ currentCase={currentCase}
740
+ imageLoaded={imageLoaded}
741
+ setImageLoaded={setImageLoaded}
742
+ files={files}
743
+ setFiles={setFiles}
744
+ showNotes={showNotes}
745
+ setShowNotes={setShowNotes}
746
+ onAnnotationRefresh={refreshAnnotationData}
747
+ isReadOnly={isReadOnlyCase}
748
+ isConfirmed={!!annotationData?.confirmationData}
749
+ confirmationSaveVersion={confirmationSaveVersion}
750
+ isUploading={isUploading}
751
+ onUploadStatusChange={setIsUploading}
752
+ />
753
+ <main className={styles.mainContent}>
380
754
  <div className={styles.canvasArea}>
381
755
  <div className={styles.toolbarWrapper}>
382
756
  <Toolbar
@@ -404,11 +778,82 @@ export const Striae = ({ user }: StriaePage) => {
404
778
  boxAnnotationColor={boxAnnotationColor}
405
779
  onAnnotationUpdate={handleAnnotationUpdate}
406
780
  isReadOnly={isReadOnlyCase}
781
+ isArchivedCase={archiveDetails.archived}
407
782
  caseNumber={currentCase}
408
783
  currentImageId={imageId}
409
784
  />
410
785
  </div>
411
- </main>
786
+ </main>
787
+ </div>
788
+ <OpenCaseModal
789
+ isOpen={isOpenCaseModalOpen}
790
+ isSubmitting={isOpeningCase}
791
+ helperText={openCaseHelperText}
792
+ onClose={() => setIsOpenCaseModalOpen(false)}
793
+ onSubmit={handleOpenCaseSubmit}
794
+ />
795
+ <CasesModal
796
+ isOpen={isListCasesModalOpen}
797
+ onClose={() => setIsListCasesModalOpen(false)}
798
+ onSelectCase={(selectedCase) => {
799
+ void loadCaseIntoWorkspace(selectedCase);
800
+ }}
801
+ currentCase={currentCase || ''}
802
+ user={user}
803
+ confirmationSaveVersion={confirmationSaveVersion}
804
+ />
805
+ <FilesModal
806
+ isOpen={isFilesModalOpen}
807
+ onClose={() => setIsFilesModalOpen(false)}
808
+ onFileSelect={(file) => {
809
+ void handleImageSelect(file);
810
+ }}
811
+ currentCase={currentCase || null}
812
+ files={files}
813
+ setFiles={setFiles}
814
+ isReadOnly={isReadOnlyCase}
815
+ selectedFileId={imageId}
816
+ confirmationSaveVersion={confirmationSaveVersion}
817
+ />
818
+ <NotesEditorModal
819
+ isOpen={showNotes}
820
+ onClose={() => setShowNotes(false)}
821
+ currentCase={currentCase}
822
+ user={user}
823
+ imageId={imageId || ''}
824
+ onAnnotationRefresh={refreshAnnotationData}
825
+ originalFileName={files.find(file => file.id === imageId)?.originalFilename}
826
+ isUploading={isUploading}
827
+ showNotification={showNotification}
828
+ />
829
+ <CaseExport
830
+ isOpen={isCaseExportModalOpen}
831
+ onClose={() => setIsCaseExportModalOpen(false)}
832
+ onExport={handleExport}
833
+ onExportAll={handleExportAll}
834
+ currentCaseNumber={currentCase}
835
+ isReadOnly={isReadOnlyCase}
836
+ />
837
+ <UserAuditViewer
838
+ caseNumber={currentCase || ''}
839
+ isOpen={isAuditTrailOpen}
840
+ onClose={() => setIsAuditTrailOpen(false)}
841
+ title={`Audit Trail - Case ${currentCase}`}
842
+ />
843
+ <RenameCaseModal
844
+ isOpen={isRenameCaseModalOpen}
845
+ currentCase={currentCase}
846
+ isSubmitting={isRenamingCase || isDeletingCase || isDeletingFile || isArchivingCase}
847
+ onClose={() => setIsRenameCaseModalOpen(false)}
848
+ onSubmit={handleRenameCaseSubmit}
849
+ />
850
+ <ArchiveCaseModal
851
+ isOpen={isArchiveCaseModalOpen}
852
+ currentCase={currentCase}
853
+ isSubmitting={isArchivingCase}
854
+ onClose={() => setIsArchiveCaseModalOpen(false)}
855
+ onSubmit={handleArchiveCaseSubmit}
856
+ />
412
857
  <Toast
413
858
  message={toastMessage}
414
859
  type={toastType}