@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,53 +1,29 @@
1
1
  import type { User } from 'firebase/auth';
2
- import type * as CaseExportActions from '../../actions/case-export';
3
2
  import { useState, useEffect, useMemo, useCallback } from 'react';
4
3
  import styles from './cases.module.css';
5
- import { Toast } from '~/components/toast/toast';
6
- import { CasesModal } from './cases-modal';
7
4
  import { FilesModal } from '../files/files-modal';
8
- import { CaseExport, type ExportFormat } from '../case-export/case-export';
9
5
  import { ImageUploadZone } from '../upload/image-upload-zone';
10
- import { UserAuditViewer } from '~/components/audit/user-audit-viewer';
11
- import {
12
- validateCaseNumber,
13
- checkExistingCase,
14
- createNewCase,
15
- renameCase,
16
- deleteCase,
17
- } from '../../actions/case-manage';
18
6
  import {
19
7
  fetchFiles,
20
8
  deleteFile,
21
9
  } from '../../actions/image-manage';
22
10
  import {
23
- checkReadOnlyCaseExists
24
- } from '../../actions/case-review';
25
- import {
26
- canCreateCase,
27
11
  canUploadFile,
28
- getLimitsDescription,
29
- getUserData,
30
- getFileAnnotations
12
+ ensureCaseConfirmationSummary,
13
+ getCaseConfirmationSummary
31
14
  } from '~/utils/data';
32
- import { type FileData, type CaseActionType } from '~/types';
15
+ import { type FileData } from '~/types';
33
16
 
34
17
  interface CaseSidebarProps {
35
18
  user: User;
36
19
  onImageSelect: (file: FileData) => void;
37
- onCaseChange: (caseNumber: string) => void;
20
+ onOpenCase: () => void;
38
21
  imageLoaded: boolean;
39
22
  setImageLoaded: (loaded: boolean) => void;
40
23
  onNotesClick: () => void;
41
24
  files: FileData[];
42
25
  setFiles: React.Dispatch<React.SetStateAction<FileData[]>>;
43
- caseNumber: string;
44
- setCaseNumber: (caseNumber: string) => void;
45
26
  currentCase: string | null;
46
- setCurrentCase: (caseNumber: string) => void;
47
- error: string;
48
- setError: (error: string) => void;
49
- successAction: CaseActionType;
50
- setSuccessAction: (action: CaseActionType) => void;
51
27
  isReadOnly?: boolean;
52
28
  isConfirmed?: boolean;
53
29
  confirmationSaveVersion?: number;
@@ -57,37 +33,16 @@ interface CaseSidebarProps {
57
33
  onUploadComplete?: (result: { successCount: number; failedFiles: string[] }) => void;
58
34
  }
59
35
 
60
- const SUCCESS_MESSAGE_TIMEOUT = 3000;
61
-
62
- type CaseExportActionsModule = typeof CaseExportActions;
63
-
64
- let caseExportActionsPromise: Promise<CaseExportActionsModule> | null = null;
65
-
66
- const loadCaseExportActions = (): Promise<CaseExportActionsModule> => {
67
- if (!caseExportActionsPromise) {
68
- caseExportActionsPromise = import('../../actions/case-export');
69
- }
70
-
71
- return caseExportActionsPromise;
72
- };
73
-
74
36
  export const CaseSidebar = ({
75
37
  user,
76
38
  onImageSelect,
77
- onCaseChange,
39
+ onOpenCase,
78
40
  imageLoaded,
79
41
  setImageLoaded,
80
42
  onNotesClick,
81
43
  files,
82
44
  setFiles,
83
- caseNumber,
84
- setCaseNumber,
85
45
  currentCase,
86
- setCurrentCase,
87
- error,
88
- setError,
89
- successAction,
90
- setSuccessAction,
91
46
  isReadOnly = false,
92
47
  isConfirmed = false,
93
48
  confirmationSaveVersion = 0,
@@ -97,27 +52,11 @@ export const CaseSidebar = ({
97
52
  onUploadComplete
98
53
  }: CaseSidebarProps) => {
99
54
 
100
- const [isDeletingCase, setIsDeletingCase] = useState(false);
101
- const [isRenaming, setIsRenaming] = useState(false);
102
- const [isLoading, setIsLoading] = useState(false);
103
- const [isModalOpen, setIsModalOpen] = useState(false);
104
55
  const [, setFileError] = useState('');
105
- const [newCaseName, setNewCaseName] = useState('');
106
- const [showCaseActions, setShowCaseActions] = useState(false);
107
- const [showCaseManagement, setShowCaseManagement] = useState(false);
108
- const [canCreateNewCase, setCanCreateNewCase] = useState(true);
109
- const [isToastVisible, setIsToastVisible] = useState(false);
110
- const [toastMessage, setToastMessage] = useState('');
111
- const [toastType, setToastType] = useState<'success' | 'error' | 'warning'>('success');
112
56
  const [canUploadNewFile, setCanUploadNewFile] = useState(true);
113
- const [createCaseError, setCreateCaseError] = useState('');
114
57
  const [uploadFileError, setUploadFileError] = useState('');
115
- const [limitsDescription, setLimitsDescription] = useState('');
116
- const [permissionChecking, setPermissionChecking] = useState(false);
117
- const [isExportModalOpen, setIsExportModalOpen] = useState(false);
118
58
  const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
119
59
  const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
120
- const [isAuditTrailOpen, setIsAuditTrailOpen] = useState(false);
121
60
  const [fileConfirmationStatus, setFileConfirmationStatus] = useState<{
122
61
  [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean }
123
62
  }>({});
@@ -131,45 +70,6 @@ export const CaseSidebar = ({
131
70
  [files]
132
71
  );
133
72
 
134
- const calculateCaseConfirmationStatus = useCallback((
135
- statuses: { [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean } }
136
- ) => {
137
- const filesRequiringConfirmation = files
138
- .map((file) => statuses[file.id] || { includeConfirmation: false, isConfirmed: false })
139
- .filter((status) => status.includeConfirmation);
140
-
141
- const allConfirmedFiles = filesRequiringConfirmation.every((status) => status.isConfirmed);
142
-
143
- return {
144
- includeConfirmation: filesRequiringConfirmation.length > 0,
145
- isConfirmed: filesRequiringConfirmation.length > 0 ? allConfirmedFiles : false,
146
- };
147
- }, [files]);
148
-
149
- // Function to check user permissions (extracted for reuse)
150
- const checkUserPermissions = useCallback(async () => {
151
- setPermissionChecking(true);
152
- try {
153
- const casePermission = await canCreateCase(user);
154
- setCanCreateNewCase(casePermission.canCreate);
155
- setCreateCaseError(casePermission.reason || '');
156
-
157
- // Only show limits description for restricted accounts
158
- const userData = await getUserData(user);
159
- if (userData && !userData.permitted) {
160
- const description = await getLimitsDescription(user);
161
- setLimitsDescription(description);
162
- } else {
163
- setLimitsDescription(''); // Clear the description for permitted users
164
- }
165
- } catch (error) {
166
- console.error('Error checking user permissions:', error);
167
- setCreateCaseError('Unable to verify account permissions');
168
- } finally {
169
- setPermissionChecking(false);
170
- }
171
- }, [user]);
172
-
173
73
  // Function to check file upload permissions (extracted for reuse)
174
74
  const checkFileUploadPermissions = useCallback(async (fileCount?: number) => {
175
75
  if (currentCase) {
@@ -190,11 +90,6 @@ export const CaseSidebar = ({
190
90
  }
191
91
  }, [currentCase, files.length, user]);
192
92
 
193
- // Check user permissions on mount and when user changes
194
- useEffect(() => {
195
- checkUserPermissions();
196
- }, [checkUserPermissions]);
197
-
198
93
  // Check file upload permissions when currentCase or files change
199
94
  useEffect(() => {
200
95
  checkFileUploadPermissions();
@@ -202,7 +97,6 @@ export const CaseSidebar = ({
202
97
 
203
98
  useEffect(() => {
204
99
  if (currentCase) {
205
- setIsLoading(true);
206
100
  fetchFiles(user, currentCase, { skipValidation: true })
207
101
  .then(loadedFiles => {
208
102
  setFiles(loadedFiles);
@@ -210,9 +104,6 @@ export const CaseSidebar = ({
210
104
  .catch(err => {
211
105
  console.error('Failed to load files:', err);
212
106
  setFileError(err instanceof Error ? err.message : 'Failed to load files');
213
- })
214
- .finally(() => {
215
- setIsLoading(false);
216
107
  });
217
108
  } else {
218
109
  setFiles([]);
@@ -232,43 +123,24 @@ export const CaseSidebar = ({
232
123
  return;
233
124
  }
234
125
 
235
- // Fetch all annotations in parallel
236
- const annotationPromises = files.map(async (file) => {
237
- try {
238
- const annotations = await getFileAnnotations(user, currentCase, file.id);
239
- return {
240
- fileId: file.id,
241
- includeConfirmation: annotations?.includeConfirmation ?? false,
242
- isConfirmed: !!annotations?.confirmationData,
243
- };
244
- } catch (err) {
245
- console.error(`Error fetching annotations for file ${file.id}:`, err);
246
- return {
247
- fileId: file.id,
248
- includeConfirmation: false,
249
- isConfirmed: false,
250
- };
251
- }
126
+ const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files).catch((error) => {
127
+ console.error(`Error fetching confirmation summary for case ${currentCase}:`, error);
128
+ return null;
252
129
  });
253
130
 
254
- // Wait for all fetches to complete
255
- const results = await Promise.all(annotationPromises);
256
-
257
- // Build the statuses map from results
258
- const statuses: { [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
259
- results.forEach((result) => {
260
- statuses[result.fileId] = {
261
- includeConfirmation: result.includeConfirmation,
262
- isConfirmed: result.isConfirmed,
263
- };
264
- });
131
+ if (!caseSummary) {
132
+ return;
133
+ }
265
134
 
266
135
  if (isCancelled) {
267
136
  return;
268
137
  }
269
138
 
270
- setFileConfirmationStatus(statuses);
271
- setCaseConfirmationStatus(calculateCaseConfirmationStatus(statuses));
139
+ setFileConfirmationStatus(caseSummary.filesById);
140
+ setCaseConfirmationStatus({
141
+ includeConfirmation: caseSummary.includeConfirmation,
142
+ isConfirmed: caseSummary.isConfirmed
143
+ });
272
144
  };
273
145
 
274
146
  fetchConfirmationStatuses();
@@ -276,7 +148,7 @@ export const CaseSidebar = ({
276
148
  return () => {
277
149
  isCancelled = true;
278
150
  };
279
- }, [currentCase, fileIdsKey, user, files, calculateCaseConfirmationStatus]);
151
+ }, [currentCase, fileIdsKey, user, files]);
280
152
 
281
153
  // Refresh only selected file confirmation status after confirmation-related data is persisted
282
154
  useEffect(() => {
@@ -288,24 +160,18 @@ export const CaseSidebar = ({
288
160
  }
289
161
 
290
162
  try {
291
- const annotations = await getFileAnnotations(user, currentCase, selectedFileId);
292
- const selectedStatus = {
293
- includeConfirmation: annotations?.includeConfirmation ?? false,
294
- isConfirmed: !!annotations?.confirmationData,
295
- };
163
+ const caseSummary =
164
+ await getCaseConfirmationSummary(user, currentCase) ||
165
+ await ensureCaseConfirmationSummary(user, currentCase, files);
296
166
 
297
167
  if (isCancelled) {
298
168
  return;
299
169
  }
300
170
 
301
- setFileConfirmationStatus((previous) => {
302
- const next = {
303
- ...previous,
304
- [selectedFileId]: selectedStatus,
305
- };
306
-
307
- setCaseConfirmationStatus(calculateCaseConfirmationStatus(next));
308
- return next;
171
+ setFileConfirmationStatus(caseSummary.filesById);
172
+ setCaseConfirmationStatus({
173
+ includeConfirmation: caseSummary.includeConfirmation,
174
+ isConfirmed: caseSummary.isConfirmed
309
175
  });
310
176
  } catch (err) {
311
177
  console.error(`Error refreshing confirmation status for file ${selectedFileId}:`, err);
@@ -317,90 +183,7 @@ export const CaseSidebar = ({
317
183
  return () => {
318
184
  isCancelled = true;
319
185
  };
320
- }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, files.length, calculateCaseConfirmationStatus]);
321
-
322
- useEffect(() => {
323
- if (error) {
324
- setToastMessage(error);
325
- setToastType('error');
326
- setIsToastVisible(true);
327
- }
328
- }, [error]);
329
-
330
- useEffect(() => {
331
- if (successAction) {
332
- setToastMessage(`Case ${currentCase} ${successAction} successfully!`);
333
- setToastType('success');
334
- setIsToastVisible(true);
335
- }
336
- // currentCase intentionally omitted: we capture its value at the time successAction changes
337
- // eslint-disable-next-line react-hooks/exhaustive-deps
338
- }, [successAction]);
339
-
340
- const handleCase = async () => {
341
- setIsLoading(true);
342
- setError('');
343
- setCreateCaseError(''); // Clear permission errors when starting new operation
344
-
345
- if (!validateCaseNumber(caseNumber)) {
346
- setError('Invalid case number format');
347
- setIsLoading(false);
348
- return;
349
- }
350
-
351
- try {
352
- const existingCase = await checkExistingCase(user, caseNumber);
353
-
354
- if (existingCase) {
355
- // Loading existing case - always allowed
356
- setCurrentCase(caseNumber);
357
- onCaseChange(caseNumber);
358
- const files = await fetchFiles(user, caseNumber, { skipValidation: true });
359
- setFiles(files);
360
- setCaseNumber('');
361
- setSuccessAction('loaded');
362
- setShowCaseManagement(false);
363
- setTimeout(() => setSuccessAction(null), SUCCESS_MESSAGE_TIMEOUT);
364
- return;
365
- }
366
-
367
- // Check if a read-only case with this number exists
368
- const existingReadOnlyCase = await checkReadOnlyCaseExists(user, caseNumber);
369
- if (existingReadOnlyCase) {
370
- setError(`Case "${caseNumber}" already exists as a read-only review case. You cannot create a case with the same number.`);
371
- setIsLoading(false);
372
- return;
373
- }
374
-
375
- // Creating new case - check permissions
376
- if (!canCreateNewCase) {
377
- setError(createCaseError || 'You cannot create more cases.');
378
- setCreateCaseError(''); // Clear duplicate error
379
- setIsLoading(false);
380
- return;
381
- }
382
-
383
- const newCase = await createNewCase(user, caseNumber);
384
- setCurrentCase(newCase.caseNumber);
385
- onCaseChange(newCase.caseNumber);
386
- setFiles([]);
387
- setCaseNumber('');
388
- setSuccessAction('created');
389
- setShowCaseManagement(false);
390
- setTimeout(() => setSuccessAction(null), SUCCESS_MESSAGE_TIMEOUT);
391
-
392
- // Refresh permissions after successful case creation
393
- // This updates the UI for users with limited permissions
394
- await checkUserPermissions();
395
- } catch (err) {
396
- setError(err instanceof Error ? err.message : 'Failed to load/create case');
397
- console.error(err);
398
- } finally {
399
- setIsLoading(false);
400
- }
401
- };
402
-
403
-
186
+ }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, files]);
404
187
 
405
188
  const handleFileDelete = async (fileId: string) => {
406
189
  // Don't allow file deletion for read-only cases
@@ -414,11 +197,15 @@ export const CaseSidebar = ({
414
197
  setDeletingFileId(fileId);
415
198
 
416
199
  try {
417
- await deleteFile(user, currentCase, fileId);
200
+ const deleteResult = await deleteFile(user, currentCase, fileId);
418
201
  const updatedFiles = files.filter(f => f.id !== fileId);
419
202
  setFiles(updatedFiles);
420
203
  onImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
421
204
  setImageLoaded(false);
205
+
206
+ if (deleteResult.imageMissing) {
207
+ setFileError(`File record deleted. Image asset "${deleteResult.fileName}" was not found and was skipped.`);
208
+ }
422
209
 
423
210
  // Refresh file upload permissions after successful file deletion
424
211
  // Pass the new file count directly to avoid state update timing issues
@@ -430,79 +217,6 @@ export const CaseSidebar = ({
430
217
  }
431
218
  };
432
219
 
433
- const handleRenameCase = async () => {
434
- // Don't allow renaming read-only cases
435
- if (isReadOnly) {
436
- return;
437
- }
438
-
439
- if (!currentCase || !newCaseName) return;
440
-
441
- if (!validateCaseNumber(newCaseName)) {
442
- setError('Invalid new case number format');
443
- return;
444
- }
445
-
446
- setIsRenaming(true);
447
- setError('');
448
-
449
- try {
450
- // Check if a read-only case with the new name exists
451
- const existingReadOnlyCase = await checkReadOnlyCaseExists(user, newCaseName);
452
- if (existingReadOnlyCase) {
453
- setError(`Case "${newCaseName}" already exists as a read-only review case. You cannot rename to this case number.`);
454
- setIsRenaming(false);
455
- return;
456
- }
457
-
458
- await renameCase(user, currentCase, newCaseName);
459
- setCurrentCase(newCaseName);
460
- onCaseChange(newCaseName);
461
- setNewCaseName('');
462
- setSuccessAction('loaded');
463
- setTimeout(() => setSuccessAction(null), SUCCESS_MESSAGE_TIMEOUT);
464
- } catch (err) {
465
- setError(err instanceof Error ? err.message : 'Failed to rename case');
466
- } finally {
467
- setIsRenaming(false);
468
- }
469
- };
470
-
471
- const handleDeleteCase = async () => {
472
- // Don't allow deleting read-only cases
473
- if (isReadOnly) {
474
- return;
475
- }
476
-
477
- if (!currentCase) return;
478
-
479
- const confirmed = window.confirm(
480
- `Are you sure you want to delete case ${currentCase}? This will permanently delete all associated files and cannot be undone.`
481
- );
482
-
483
- if (!confirmed) return;
484
-
485
- setIsDeletingCase(true);
486
- setError('');
487
-
488
- try {
489
- await deleteCase(user, currentCase);
490
- setCurrentCase('');
491
- onCaseChange('');
492
- setFiles([]);
493
- setSuccessAction('deleted');
494
- setTimeout(() => setSuccessAction(null), SUCCESS_MESSAGE_TIMEOUT);
495
-
496
- // Refresh permissions after successful case deletion
497
- // This allows users with limited permissions to create a new case
498
- await checkUserPermissions();
499
- } catch (err) {
500
- setError(err instanceof Error ? err.message : 'Failed to delete case');
501
- } finally {
502
- setIsDeletingCase(false);
503
- }
504
- };
505
-
506
220
  const handleImageSelect = (file: FileData) => {
507
221
  onImageSelect(file);
508
222
  // Prevent notes from opening against stale image state while selection loads.
@@ -539,155 +253,10 @@ const handleImageSelect = (file: FileData) => {
539
253
  ? 'Select an image first'
540
254
  : undefined;
541
255
 
542
- const handleExport = async (exportCaseNumber: string, format: ExportFormat, includeImages?: boolean, onProgress?: (progress: number, label: string) => void) => {
543
- try {
544
- const caseExportActions = await loadCaseExportActions();
545
-
546
- if (includeImages) {
547
- // ZIP export with images - only available for single case exports
548
- await caseExportActions.downloadCaseAsZip(user, exportCaseNumber, format, (progress) => {
549
- const label = progress < 30 ? 'Loading case data' :
550
- progress < 50 ? 'Preparing archive' :
551
- progress < 80 ? 'Adding images' :
552
- progress < 96 ? 'Finalizing' : 'Downloading';
553
- onProgress?.(Math.round(progress), label);
554
- });
555
- } else {
556
- // Standard data-only export
557
- onProgress?.(5, 'Loading case data');
558
- const exportData = await caseExportActions.exportCaseData(
559
- user,
560
- exportCaseNumber,
561
- { includeMetadata: true },
562
- (current, total, label) => {
563
- const p = total > 0 ? Math.round(10 + (current / total) * 60) : 10;
564
- onProgress?.(p, label);
565
- }
566
- );
567
- onProgress?.(75, 'Preparing download');
568
-
569
- // Download the exported data in the selected format
570
- if (format === 'json') {
571
- await caseExportActions.downloadCaseAsJSON(user, exportData);
572
- } else {
573
- await caseExportActions.downloadCaseAsCSV(user, exportData);
574
- }
575
- onProgress?.(100, 'Complete');
576
- }
577
-
578
- } catch (error) {
579
- console.error('Export failed:', error);
580
- throw error; // Re-throw to be handled by the modal
581
- }
582
- };
583
-
584
- const handleExportAll = async (onProgress: (current: number, total: number, caseName: string) => void, format: ExportFormat) => {
585
- try {
586
- const caseExportActions = await loadCaseExportActions();
587
-
588
- // Export all cases with progress callback
589
- const exportData = await caseExportActions.exportAllCases(user, {
590
- includeMetadata: true
591
- }, onProgress);
592
-
593
- // Download the exported data in the selected format
594
- if (format === 'json') {
595
- await caseExportActions.downloadAllCasesAsJSON(user, exportData);
596
- } else {
597
- await caseExportActions.downloadAllCasesAsCSV(user, exportData);
598
- }
599
-
600
- } catch (error) {
601
- console.error('Export all failed:', error);
602
- throw error; // Re-throw to be handled by the modal
603
- }
604
- };
605
-
606
256
  return (
607
257
  <>
608
258
  <div className={styles.caseSection}>
609
- {currentCase && !showCaseManagement ? (
610
- <div className={`${styles.caseLoad} mb-4`}>
611
- <button
612
- className={styles.switchCaseButton}
613
- onClick={() => setShowCaseManagement(true)}
614
- disabled={isUploading}
615
- title={isUploading ? "Cannot switch cases while uploading files" : undefined}
616
- >
617
- Switch Case
618
- </button>
619
- </div>
620
- ) : (
621
- <>
622
- <h4>Case Management</h4>
623
- {limitsDescription && (
624
- <p className={styles.limitsInfo}>
625
- {limitsDescription}
626
- </p>
627
- )}
628
- <div className={`${styles.caseInput} mb-4`}>
629
- <input
630
- type="text"
631
- value={caseNumber}
632
- onChange={(e) => setCaseNumber(e.target.value)}
633
- placeholder="Case #"
634
- />
635
- </div>
636
- <div className={`${styles.caseLoad} mb-4`}>
637
- <button
638
- onClick={handleCase}
639
- disabled={isLoading || !caseNumber || permissionChecking || (isReadOnly && !!currentCase) || isUploading}
640
- title={
641
- isUploading
642
- ? "Cannot load/create cases while uploading files"
643
- : (isReadOnly && currentCase)
644
- ? "Cannot load/create cases while reviewing a read-only case. Clear the current case first."
645
- : (!canCreateNewCase ? createCaseError : undefined)
646
- }
647
- >
648
- {isLoading ? 'Loading...' : permissionChecking ? 'Checking permissions...' : 'Load/Create Case'}
649
- </button>
650
- </div>
651
- <div className={styles.caseInput}>
652
- <button
653
- onClick={() => setIsModalOpen(true)}
654
- className={styles.listButton}
655
- disabled={isUploading}
656
- title={isUploading ? "Cannot list cases while uploading files" : undefined}
657
- >
658
- List All Cases
659
- </button>
660
- </div>
661
- {currentCase && (
662
- <div className="mb-4">
663
- <button
664
- className={styles.cancelSwitchButton}
665
- onClick={() => setShowCaseManagement(false)}
666
- disabled={isUploading}
667
- >
668
- Cancel
669
- </button>
670
- </div>
671
- )}
672
- </>
673
- )}
674
- <CasesModal
675
- isOpen={isModalOpen}
676
- onClose={() => setIsModalOpen(false)}
677
- onSelectCase={setCaseNumber}
678
- currentCase={currentCase || ''}
679
- user={user}
680
- />
681
-
682
- <CaseExport
683
- isOpen={isExportModalOpen}
684
- onClose={() => setIsExportModalOpen(false)}
685
- onExport={handleExport}
686
- onExportAll={handleExportAll}
687
- currentCaseNumber={currentCase || ''}
688
- isReadOnly={isReadOnly}
689
- />
690
-
259
+
691
260
  <FilesModal
692
261
  isOpen={isFilesModalOpen}
693
262
  onClose={() => setIsFilesModalOpen(false)}
@@ -697,21 +266,29 @@ return (
697
266
  setFiles={setFiles}
698
267
  isReadOnly={isReadOnly}
699
268
  selectedFileId={selectedFileId}
269
+ confirmationSaveVersion={confirmationSaveVersion}
700
270
  />
701
271
 
702
272
  <div className={styles.filesSection}>
703
- <div className={isReadOnly && currentCase ? styles.readOnlyContainer : styles.caseHeader}>
704
- <h4 className={`${styles.caseNumber} ${
705
- currentCase && caseConfirmationStatus.includeConfirmation
706
- ? caseConfirmationStatus.isConfirmed
707
- ? styles.caseConfirmed
708
- : styles.caseNotConfirmed
709
- : ''
710
- }`}>
711
- {currentCase || 'No Case Selected'}
712
- </h4>
713
- {isReadOnly && currentCase && (
714
- <div className={styles.readOnlyBadge}>(Read-Only)</div>
273
+ <div className={currentCase ? (isReadOnly ? styles.readOnlyContainer : styles.caseHeader) : styles.emptyCaseHeader}>
274
+ {currentCase ? (
275
+ <h4 className={`${styles.caseNumber} ${
276
+ caseConfirmationStatus.includeConfirmation
277
+ ? caseConfirmationStatus.isConfirmed
278
+ ? styles.caseConfirmed
279
+ : styles.caseNotConfirmed
280
+ : ''
281
+ }`}>
282
+ {currentCase}
283
+ </h4>
284
+ ) : (
285
+ <button
286
+ type="button"
287
+ className={styles.openCaseButton}
288
+ onClick={onOpenCase}
289
+ >
290
+ Open Case
291
+ </button>
715
292
  )}
716
293
  </div>
717
294
  {currentCase && (
@@ -743,25 +320,18 @@ return (
743
320
  </div>
744
321
  )}
745
322
 
746
- {!currentCase ? (
747
- <p className={styles.emptyState}>Create or select a case to view files</p>
748
- ) : files.length === 0 ? (
749
- <p className={styles.emptyState}>No files found for {currentCase}</p>
750
- ) : (
751
- <>
752
- {!canUploadNewFile && (
753
- <div className={styles.limitReached}>
754
- <p>Upload limit reached for this case</p>
755
- </div>
756
- )}
757
- <ul className={styles.fileList}>
758
- {files.map((file) => {
323
+ {currentCase ? (
324
+ <ul className={styles.fileList}>
325
+ {files.length === 0 ? (
326
+ <li className={styles.fileListMessage}>No files found for {currentCase}</li>
327
+ ) : (
328
+ files.map((file) => {
759
329
  const confirmationStatus = fileConfirmationStatus[file.id];
760
330
  let confirmationClass = '';
761
-
331
+
762
332
  if (confirmationStatus?.includeConfirmation) {
763
- confirmationClass = confirmationStatus.isConfirmed
764
- ? styles.fileItemConfirmed
333
+ confirmationClass = confirmationStatus.isConfirmed
334
+ ? styles.fileItemConfirmed
765
335
  : styles.fileItemNotConfirmed;
766
336
  }
767
337
 
@@ -776,11 +346,11 @@ return (
776
346
  title={isUploading ? "Cannot select files while uploading" : undefined}
777
347
  >
778
348
  <span className={styles.fileName}>{file.originalFilename}</span>
779
- </button>
349
+ </button>
780
350
  <button
781
351
  onClick={() => {
782
352
  if (window.confirm('Are you sure you want to delete this file? This action cannot be undone.')) {
783
- handleFileDelete(file.id);
353
+ handleFileDelete(file.id);
784
354
  }
785
355
  }}
786
356
  className={styles.deleteButton}
@@ -793,12 +363,14 @@ return (
793
363
  </button>
794
364
  </li>
795
365
  );
796
- })}
797
- </ul>
798
- </>
366
+ })
367
+ )}
368
+ </ul>
369
+ ) : (
370
+ <div className={styles.fileListPlaceholder}>Select a case to view files</div>
799
371
  )}
800
372
  </div>
801
- <div className={`${styles.sidebarToggle} mb-4`}>
373
+ <div className={styles.sidebarToggle}>
802
374
  <button
803
375
  onClick={onNotesClick}
804
376
  disabled={isImageNotesDisabled}
@@ -807,99 +379,7 @@ return (
807
379
  Image Notes
808
380
  </button>
809
381
  </div>
810
- {currentCase && (
811
- <div className={styles.caseActionsSection}>
812
- <button
813
- onClick={() => setShowCaseActions(!showCaseActions)}
814
- className={styles.caseActionsButton}
815
- disabled={isUploading}
816
- title={isUploading ? "Cannot access case actions while uploading" : undefined}
817
- >
818
- {showCaseActions ? 'Hide Case Actions' : 'Case Actions'}
819
- </button>
820
-
821
- {showCaseActions && !isUploading && (
822
- <div className={styles.caseActionsContent}>
823
- {/* Export Case Data Section */}
824
- <div className={styles.exportSection}>
825
- <button
826
- onClick={() => setIsExportModalOpen(true)}
827
- className={styles.exportButton}
828
- disabled={isUploading}
829
- title={isUploading ? "Cannot export while uploading" : undefined}
830
- >
831
- Export Case Data
832
- </button>
833
- </div>
834
-
835
- {/* Audit Trail Section - Available for all cases */}
836
- <div className={styles.auditTrailSection}>
837
- <button
838
- onClick={() => setIsAuditTrailOpen(true)}
839
- className={styles.auditTrailButton}
840
- disabled={isUploading}
841
- title={isUploading ? "Cannot view audit trail while uploading" : undefined}
842
- >
843
- Audit Trail
844
- </button>
845
- </div>
846
-
847
- {/* Rename/Delete Section - Only for owned cases */}
848
- {!isReadOnly && (
849
- <div className={styles.renameDeleteSection}>
850
- <div className={`${styles.caseRename} mb-4`}>
851
- <input
852
- type="text"
853
- value={newCaseName}
854
- onChange={(e) => setNewCaseName(e.target.value)}
855
- placeholder="New Case Number"
856
- disabled={isUploading}
857
- />
858
- <button
859
- onClick={handleRenameCase}
860
- disabled={isRenaming || !newCaseName || isUploading}
861
- title={isUploading ? "Cannot rename while uploading" : undefined}
862
- >
863
- {isRenaming ? 'Renaming...' : 'Rename Case'}
864
- </button>
865
- </div>
866
-
867
- <div className={styles.deleteCaseSection}>
868
- <button
869
- onClick={handleDeleteCase}
870
- disabled={isDeletingCase || isUploading}
871
- className={styles.deleteWarningButton}
872
- title={isUploading ? "Cannot delete while uploading" : undefined}
873
- >
874
- {isDeletingCase ? 'Deleting...' : 'Delete Case'}
875
- </button>
876
- </div>
877
- </div>
878
- )}
879
- </div>
880
- )}
881
- </div>
882
- )}
883
-
884
- {/* Unified Audit Viewer */}
885
- <UserAuditViewer
886
- caseNumber={currentCase || ''}
887
- isOpen={isAuditTrailOpen}
888
- onClose={() => setIsAuditTrailOpen(false)}
889
- title={`Audit Trail - Case ${currentCase}`}
890
- />
891
-
892
382
  </div>
893
- <Toast
894
- message={toastMessage}
895
- type={toastType}
896
- isVisible={isToastVisible}
897
- onClose={() => {
898
- setIsToastVisible(false);
899
- setError('');
900
- setSuccessAction(null);
901
- }}
902
- />
903
383
  </>
904
384
  );
905
385
  };