@striae-org/striae 4.3.2 → 4.3.3

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.
@@ -287,7 +287,7 @@ export async function importCaseForReview(
287
287
  throw new Error(`Case "${result.caseNumber}" already exists in your case list. You cannot import a case for review if you were the original analyst.`);
288
288
  }
289
289
  if (existingRegularCase && isArchivedExport) {
290
- throw new Error(`Cannot import this archive because case "${result.caseNumber}" already exists in your case list (active or archived). To import this archive, the existing case must first be deleted.`);
290
+ throw new Error(`Cannot import this archived case because "${result.caseNumber}" already exists in your regular case list. Delete the regular case before importing this archive.`);
291
291
  }
292
292
 
293
293
  // Step 2b: Check if read-only case already exists
@@ -182,8 +182,9 @@ export const checkExistingCase = async (user: User, caseNumber: string): Promise
182
182
  return null;
183
183
  }
184
184
 
185
- // Check if this is a read-only case - if so, don't consider it as an existing regular case
186
- if ('isReadOnly' in caseData && caseData.isReadOnly) {
185
+ // Imported review cases are read-only and should not be treated as regular cases.
186
+ // Archived cases remain regular case records even if legacy data includes isReadOnly.
187
+ if ('isReadOnly' in caseData && caseData.isReadOnly && !caseData.archived) {
187
188
  return null;
188
189
  }
189
190
 
@@ -212,11 +213,6 @@ export const checkCaseIsReadOnly = async (user: User, caseNumber: string): Promi
212
213
  return false;
213
214
  }
214
215
 
215
- // Archived cases are always treated as read-only.
216
- if (caseData.archived) {
217
- return true;
218
- }
219
-
220
216
  // Use type guard to check for isReadOnly property safely
221
217
  return isReadOnlyCaseData(caseData) ? !!caseData.isReadOnly : false;
222
218
 
@@ -748,7 +744,7 @@ export const archiveCase = async (
748
744
  archivedBy: user.uid,
749
745
  archivedByDisplay,
750
746
  archiveReason: archiveReason?.trim() || undefined,
751
- isReadOnly: true,
747
+ isReadOnly: false,
752
748
  } as CaseData;
753
749
 
754
750
  const exportData = await exportCaseData(user, caseNumber, { includeMetadata: true });
@@ -13,6 +13,7 @@ interface NavbarProps {
13
13
  isUploading?: boolean;
14
14
  company?: string;
15
15
  isReadOnly?: boolean;
16
+ isReviewOnlyCase?: boolean;
16
17
  currentCase?: string;
17
18
  currentFileName?: string;
18
19
  isCurrentImageConfirmed?: boolean;
@@ -43,6 +44,7 @@ export const Navbar = ({
43
44
  isUploading = false,
44
45
  company,
45
46
  isReadOnly = false,
47
+ isReviewOnlyCase = false,
46
48
  currentCase,
47
49
  currentFileName,
48
50
  isCurrentImageConfirmed = false,
@@ -119,16 +121,17 @@ export const Navbar = ({
119
121
  const disableLongRunningCaseActions = isUploading;
120
122
  const isCaseManagementActive = true;
121
123
  const isFileManagementActive = isFileMenuOpen || hasLoadedImage;
122
- const canOpenImageNotes = hasLoadedImage && !isCurrentImageConfirmed;
124
+ const canOpenImageNotes = hasLoadedImage && !isCurrentImageConfirmed && !isReadOnly;
123
125
  const isImageNotesActive = canOpenImageNotes;
124
126
  const canDeleteCurrentFile = hasLoadedImage && !isReadOnly;
127
+ const isArchivedRegularReadOnly = Boolean(isReadOnly && archiveDetails?.archived && !isReviewOnlyCase);
125
128
 
126
129
  return (
127
130
  <>
128
131
  <header className={styles.navbar} aria-label="Canvas top navigation">
129
132
  <div className={styles.companyLabelContainer}>
130
133
  <div className={styles.companyLabel}>
131
- {isReadOnly ? 'CASE REVIEW ONLY' : `${company}${user?.displayName ? ` | ${user.displayName}` : ''}${userBadgeId ? `, ${userBadgeId}` : ''}`}
134
+ {isReviewOnlyCase ? 'CASE REVIEW ONLY' : `${company}${user?.displayName ? ` | ${user.displayName}` : ''}${userBadgeId ? `, ${userBadgeId}` : ''}`}
132
135
  </div>
133
136
  </div>
134
137
  <div className={styles.navCenterTrack}>
@@ -153,8 +156,8 @@ export const Navbar = ({
153
156
  type="button"
154
157
  role="menuitem"
155
158
  className={`${styles.caseMenuItem} ${styles.caseMenuItemOpen}`}
156
- disabled={isReadOnly}
157
- title={isReadOnly ? 'Clear the read-only case first to open or switch cases' : undefined}
159
+ disabled={isReviewOnlyCase}
160
+ title={isReviewOnlyCase ? 'Clear the read-only case first to open or switch cases' : undefined}
158
161
  onClick={() => {
159
162
  onOpenCase?.();
160
163
  setIsCaseMenuOpen(false);
@@ -166,8 +169,8 @@ export const Navbar = ({
166
169
  type="button"
167
170
  role="menuitem"
168
171
  className={`${styles.caseMenuItem} ${styles.caseMenuItemList}`}
169
- disabled={isReadOnly}
170
- title={isReadOnly ? 'Clear the read-only case first to list all cases' : undefined}
172
+ disabled={isReviewOnlyCase}
173
+ title={isReviewOnlyCase ? 'Clear the read-only case first to list all cases' : undefined}
171
174
  onClick={() => {
172
175
  onOpenListAllCases?.();
173
176
  setIsCaseMenuOpen(false);
@@ -180,9 +183,11 @@ export const Navbar = ({
180
183
  type="button"
181
184
  role="menuitem"
182
185
  className={`${styles.caseMenuItem} ${styles.caseMenuItemExport}`}
183
- disabled={!hasLoadedCase || disableLongRunningCaseActions}
186
+ disabled={!hasLoadedCase || disableLongRunningCaseActions || isArchivedRegularReadOnly}
184
187
  title={
185
- !hasLoadedCase
188
+ isArchivedRegularReadOnly
189
+ ? 'Export is unavailable for archived cases loaded from your regular case list'
190
+ : !hasLoadedCase
186
191
  ? 'Load a case to export case data'
187
192
  : disableLongRunningCaseActions
188
193
  ? 'Export is unavailable while files are uploading'
@@ -209,7 +214,7 @@ export const Navbar = ({
209
214
  Case Audit Trail
210
215
  </button>
211
216
  <div className={styles.caseMenuSectionLabel}>Maintenance</div>
212
- {isReadOnly && (
217
+ {isReviewOnlyCase && (
213
218
  <button
214
219
  type="button"
215
220
  role="menuitem"
@@ -249,9 +254,9 @@ export const Navbar = ({
249
254
  type="button"
250
255
  role="menuitem"
251
256
  className={`${styles.caseMenuItem} ${styles.caseMenuItemDelete}`}
252
- disabled={!hasLoadedCase || disableLongRunningCaseActions || isReadOnly}
257
+ disabled={!hasLoadedCase || disableLongRunningCaseActions || isReviewOnlyCase}
253
258
  title={
254
- isReadOnly
259
+ isReviewOnlyCase
255
260
  ? 'Clear the read-only case first before deleting'
256
261
  : !hasLoadedCase
257
262
  ? 'Load a case to delete it'
@@ -370,7 +375,15 @@ export const Navbar = ({
370
375
  className={`${styles.navSectionButton} ${isImageNotesActive ? styles.navSectionButtonActive : ''}`}
371
376
  disabled={!canOpenImageNotes}
372
377
  aria-pressed={isImageNotesActive}
373
- title={!hasLoadedImage ? 'Load an image to enable image notes' : isCurrentImageConfirmed ? 'Confirmed images are read-only and viewable via toolbar only' : undefined}
378
+ title={
379
+ !hasLoadedImage
380
+ ? 'Load an image to enable image notes'
381
+ : isCurrentImageConfirmed
382
+ ? 'Confirmed images are read-only and viewable via toolbar only'
383
+ : isReadOnly
384
+ ? 'Image notes are disabled for read-only cases'
385
+ : undefined
386
+ }
374
387
  onClick={() => {
375
388
  onOpenImageNotes?.();
376
389
  }}
@@ -1,6 +1,12 @@
1
1
  import { useState, useEffect, useRef, useContext, useCallback } from 'react';
2
2
  import { AuthContext } from '~/contexts/auth.context';
3
3
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
4
+ import {
5
+ ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE,
6
+ IMPORT_FILE_TYPE_NOT_ALLOWED,
7
+ IMPORT_FILE_TYPE_NOT_SUPPORTED,
8
+ DATA_INTEGRITY_BLOCKED_TAMPERING
9
+ } from '~/utils/ui';
4
10
  import {
5
11
  listReadOnlyCases,
6
12
  deleteReadOnlyCase
@@ -63,13 +69,13 @@ export const CaseImport = ({
63
69
  });
64
70
 
65
71
  const [existingReadOnlyCase, setExistingReadOnlyCase] = useState<string | null>(null);
66
- const [showArchivedRegularCaseRiskWarning, setShowArchivedRegularCaseRiskWarning] = useState(false);
72
+ const [isArchivedRegularCaseImportBlocked, setIsArchivedRegularCaseImportBlocked] = useState(false);
67
73
  const fileInputRef = useRef<HTMLInputElement>(null);
68
74
 
69
75
  // Clear import selection state (used by preview hook on validation failure)
70
76
  const clearImportSelection = useCallback(() => {
71
77
  updateImportState({ selectedFile: null, importType: null });
72
- setShowArchivedRegularCaseRiskWarning(false);
78
+ setIsArchivedRegularCaseImportBlocked(false);
73
79
  resetFileInput(fileInputRef);
74
80
  }, [updateImportState]);
75
81
 
@@ -203,14 +209,14 @@ export const CaseImport = ({
203
209
  clearMessages();
204
210
 
205
211
  if (!isValidImportFile(file)) {
206
- setError('Only Striae case ZIP files, confirmation ZIP files, or confirmation JSON files are allowed.');
212
+ setError(IMPORT_FILE_TYPE_NOT_ALLOWED);
207
213
  clearImportData();
208
214
  return;
209
215
  }
210
216
 
211
217
  const importType = await resolveImportType(file);
212
218
  if (!importType) {
213
- setError('The selected file is not a supported Striae case or confirmation import package.');
219
+ setError(IMPORT_FILE_TYPE_NOT_SUPPORTED);
214
220
  clearImportData();
215
221
  return;
216
222
  }
@@ -232,6 +238,11 @@ export const CaseImport = ({
232
238
  // Handle import action
233
239
  const handleImport = useCallback(() => {
234
240
  if (!user || !importState.selectedFile || !importState.importType) return;
241
+
242
+ if (importState.importType === 'case' && isArchivedRegularCaseImportBlocked) {
243
+ setError(ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE);
244
+ return;
245
+ }
235
246
 
236
247
  // For case imports, show confirmation dialog with preview
237
248
  // For confirmation imports, proceed directly to import
@@ -242,7 +253,16 @@ export const CaseImport = ({
242
253
  // Direct import for confirmations
243
254
  executeImport();
244
255
  }
245
- }, [user, importState.selectedFile, importState.importType, casePreview, updateImportState, executeImport]);
256
+ }, [
257
+ user,
258
+ importState.selectedFile,
259
+ importState.importType,
260
+ isArchivedRegularCaseImportBlocked,
261
+ casePreview,
262
+ updateImportState,
263
+ executeImport,
264
+ setError,
265
+ ]);
246
266
 
247
267
  const handleCancelImport = useCallback(() => {
248
268
  updateImportState({ showConfirmation: false });
@@ -273,7 +293,7 @@ export const CaseImport = ({
273
293
  !casePreview.caseNumber
274
294
  ) {
275
295
  if (isMounted) {
276
- setShowArchivedRegularCaseRiskWarning(false);
296
+ setIsArchivedRegularCaseImportBlocked(false);
277
297
  }
278
298
  return;
279
299
  }
@@ -281,11 +301,12 @@ export const CaseImport = ({
281
301
  try {
282
302
  const regularCases = await listCases(user);
283
303
  if (isMounted) {
284
- setShowArchivedRegularCaseRiskWarning(regularCases.includes(casePreview.caseNumber));
304
+ const hasConflict = regularCases.includes(casePreview.caseNumber);
305
+ setIsArchivedRegularCaseImportBlocked(hasConflict);
285
306
  }
286
307
  } catch {
287
308
  if (isMounted) {
288
- setShowArchivedRegularCaseRiskWarning(false);
309
+ setIsArchivedRegularCaseImportBlocked(false);
289
310
  }
290
311
  }
291
312
  };
@@ -295,7 +316,13 @@ export const CaseImport = ({
295
316
  return () => {
296
317
  isMounted = false;
297
318
  };
298
- }, [user, isOpen, importState.importType, casePreview?.archived, casePreview?.caseNumber]);
319
+ }, [
320
+ user,
321
+ isOpen,
322
+ importState.importType,
323
+ casePreview?.archived,
324
+ casePreview?.caseNumber,
325
+ ]);
299
326
 
300
327
  // Reset state when modal closes
301
328
  useEffect(() => {
@@ -306,9 +333,19 @@ export const CaseImport = ({
306
333
 
307
334
  // Handle confirmation import
308
335
  const handleConfirmImport = useCallback(() => {
336
+ if (isArchivedRegularCaseImportBlocked) {
337
+ setError(ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE);
338
+ return;
339
+ }
340
+
309
341
  executeImport();
310
342
  updateImportState({ showConfirmation: false });
311
- }, [executeImport, updateImportState]);
343
+ }, [
344
+ isArchivedRegularCaseImportBlocked,
345
+ executeImport,
346
+ updateImportState,
347
+ setError,
348
+ ]);
312
349
 
313
350
  if (!isOpen) return null;
314
351
 
@@ -362,7 +399,7 @@ export const CaseImport = ({
362
399
  <CasePreviewSection
363
400
  casePreview={casePreview}
364
401
  isLoadingPreview={importState.isLoadingPreview}
365
- showArchivedRegularCaseRiskWarning={showArchivedRegularCaseRiskWarning}
402
+ isArchivedRegularCaseImportBlocked={isArchivedRegularCaseImportBlocked}
366
403
  />
367
404
  )}
368
405
 
@@ -381,11 +418,14 @@ export const CaseImport = ({
381
418
  {/* Hash validation warning */}
382
419
  {casePreview?.hashValid === false && (
383
420
  <div className={styles.hashWarning}>
384
- <strong>⚠️ Import Blocked:</strong> Data hash validation failed.
385
- This file may have been tampered with or corrupted and cannot be imported.
421
+ <strong>⚠️ Import Blocked:</strong> {DATA_INTEGRITY_BLOCKED_TAMPERING}
386
422
  </div>
387
423
  )}
388
424
 
425
+ {isArchivedRegularCaseImportBlocked && (
426
+ <div className={styles.error}>{ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}</div>
427
+ )}
428
+
389
429
  {/* Success message */}
390
430
  {messages.success && (
391
431
  <div className={styles.success}>
@@ -411,6 +451,7 @@ export const CaseImport = ({
411
451
  importState.isImporting ||
412
452
  importState.isClearing ||
413
453
  importState.isLoadingPreview ||
454
+ (importState.importType === 'case' && isArchivedRegularCaseImportBlocked) ||
414
455
  (importState.importType === 'case' && (!casePreview || casePreview.hashValid !== true))
415
456
  }
416
457
  >
@@ -454,7 +495,8 @@ export const CaseImport = ({
454
495
  <ConfirmationDialog
455
496
  showConfirmation={importState.showConfirmation}
456
497
  casePreview={casePreview}
457
- showArchivedRegularCaseRiskWarning={showArchivedRegularCaseRiskWarning}
498
+ isArchivedRegularCaseImportBlocked={isArchivedRegularCaseImportBlocked}
499
+ archivedRegularCaseBlockMessage={ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}
458
500
  onConfirm={handleConfirmImport}
459
501
  onCancel={handleCancelImport}
460
502
  />
@@ -1,16 +1,17 @@
1
1
  import { type CaseImportPreview } from '~/types';
2
+ import { ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE, DATA_INTEGRITY_VALIDATION_PASSED, DATA_INTEGRITY_VALIDATION_FAILED } from '~/utils/ui';
2
3
  import styles from '../case-import.module.css';
3
4
 
4
5
  interface CasePreviewSectionProps {
5
6
  casePreview: CaseImportPreview | null;
6
7
  isLoadingPreview: boolean;
7
- showArchivedRegularCaseRiskWarning?: boolean;
8
+ isArchivedRegularCaseImportBlocked?: boolean;
8
9
  }
9
10
 
10
11
  export const CasePreviewSection = ({
11
12
  casePreview,
12
13
  isLoadingPreview,
13
- showArchivedRegularCaseRiskWarning = false
14
+ isArchivedRegularCaseImportBlocked = false
14
15
  }: CasePreviewSectionProps) => {
15
16
  if (isLoadingPreview) {
16
17
  return (
@@ -34,9 +35,9 @@ export const CasePreviewSection = ({
34
35
  Archived export detected. Original exporter imports are allowed for archived cases.
35
36
  </div>
36
37
  )}
37
- {showArchivedRegularCaseRiskWarning && (
38
+ {isArchivedRegularCaseImportBlocked && (
38
39
  <div className={styles.archivedRegularCaseRiskNote}>
39
- Warning: This archived import matches a case already in your regular case list. If you later clear the imported read-only case, the regular case images will be deleted and become inaccessible.
40
+ {ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE}
40
41
  </div>
41
42
  )}
42
43
  <div className={styles.previewGrid}>
@@ -78,9 +79,9 @@ export const CasePreviewSection = ({
78
79
  <div className={styles.validationItem}>
79
80
  <span className={`${styles.validationValue} ${casePreview.hashValid ? styles.validationSuccess : styles.validationError}`}>
80
81
  {casePreview.hashValid ? (
81
- <>✓ Validation passed</>
82
+ <>{DATA_INTEGRITY_VALIDATION_PASSED}</>
82
83
  ) : (
83
- <>✗ Validation failed</>
84
+ <>{DATA_INTEGRITY_VALIDATION_FAILED}</>
84
85
  )}
85
86
  </span>
86
87
  </div>
@@ -1,10 +1,12 @@
1
1
  import { type CaseImportPreview } from '~/types';
2
+ import { ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE, DATA_INTEGRITY_VALIDATION_PASSED, DATA_INTEGRITY_VALIDATION_FAILED } from '~/utils/ui';
2
3
  import styles from '../case-import.module.css';
3
4
 
4
5
  interface ConfirmationDialogProps {
5
6
  showConfirmation: boolean;
6
7
  casePreview: CaseImportPreview | null;
7
- showArchivedRegularCaseRiskWarning?: boolean;
8
+ isArchivedRegularCaseImportBlocked?: boolean;
9
+ archivedRegularCaseBlockMessage?: string;
8
10
  onConfirm: () => void;
9
11
  onCancel: () => void;
10
12
  }
@@ -12,7 +14,8 @@ interface ConfirmationDialogProps {
12
14
  export const ConfirmationDialog = ({
13
15
  showConfirmation,
14
16
  casePreview,
15
- showArchivedRegularCaseRiskWarning = false,
17
+ isArchivedRegularCaseImportBlocked = false,
18
+ archivedRegularCaseBlockMessage = ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE,
16
19
  onConfirm,
17
20
  onCancel
18
21
  }: ConfirmationDialogProps) => {
@@ -51,16 +54,16 @@ export const ConfirmationDialog = ({
51
54
  Archived export detected. Original exporter imports are allowed for archived cases.
52
55
  </div>
53
56
  )}
54
- {showArchivedRegularCaseRiskWarning && (
57
+ {isArchivedRegularCaseImportBlocked && (
55
58
  <div className={styles.archivedRegularCaseRiskNote}>
56
- Warning: This archived import matches a case in your regular case list. If you clear the imported read-only case later, the regular case images will be deleted and inaccessible.
59
+ {archivedRegularCaseBlockMessage}
57
60
  </div>
58
61
  )}
59
62
  {casePreview.hashValid !== undefined && (
60
63
  <div className={`${styles.confirmationItem} ${casePreview.hashValid ? styles.confirmationItemValid : styles.confirmationItemInvalid}`}>
61
64
  <strong>Data Integrity:</strong>
62
65
  <span className={casePreview.hashValid ? styles.confirmationSuccess : styles.confirmationError}>
63
- {casePreview.hashValid ? '✓ Verified' : '✗ Failed'}
66
+ {casePreview.hashValid ? DATA_INTEGRITY_VALIDATION_PASSED : DATA_INTEGRITY_VALIDATION_FAILED}
64
67
  </span>
65
68
  </div>
66
69
  )}
@@ -70,6 +73,7 @@ export const ConfirmationDialog = ({
70
73
  <button
71
74
  className={styles.confirmButton}
72
75
  onClick={onConfirm}
76
+ disabled={isArchivedRegularCaseImportBlocked}
73
77
  >
74
78
  Confirm Import
75
79
  </button>
@@ -260,6 +260,19 @@
260
260
  white-space: nowrap;
261
261
  }
262
262
 
263
+ .badgeColumn {
264
+ justify-self: end;
265
+ display: flex;
266
+ flex-direction: column;
267
+ align-items: flex-end;
268
+ gap: var(--spaceXS);
269
+ }
270
+
271
+ .archivedBadge {
272
+ background-color: color-mix(in lab, #6c757d 18%, var(--backgroundLight));
273
+ border-color: color-mix(in lab, #6c757d 30%, transparent);
274
+ }
275
+
263
276
  .footerActions {
264
277
  display: flex;
265
278
  justify-content: space-between;
@@ -489,6 +502,12 @@
489
502
  justify-self: start;
490
503
  }
491
504
 
505
+ .badgeColumn {
506
+ grid-column: 1 / -1;
507
+ justify-self: start;
508
+ align-items: flex-start;
509
+ }
510
+
492
511
  .footerActions {
493
512
  flex-direction: column;
494
513
  align-items: stretch;
@@ -169,7 +169,7 @@ export const CasesModal = ({
169
169
  return allCases.filter((entry) => entry.archived && !entry.isReadOnly);
170
170
  }
171
171
 
172
- return allCases.filter((entry) => !entry.archived && !entry.isReadOnly);
172
+ return allCases.filter((entry) => !entry.isReadOnly);
173
173
  }, [allCases, preferences.showArchivedOnly]);
174
174
 
175
175
  const visibleCases = useMemo(() => {
@@ -185,6 +185,11 @@ export const CasesModal = ({
185
185
  );
186
186
  }, [allCases, preferences, caseConfirmationStatus, searchQuery]);
187
187
 
188
+ const totalRegularCases = useMemo(
189
+ () => allCases.filter((entry) => !entry.isReadOnly).length,
190
+ [allCases]
191
+ );
192
+
188
193
  const totalPages = Math.max(1, Math.ceil(visibleCases.length / CASES_PER_PAGE));
189
194
 
190
195
  useEffect(() => {
@@ -653,7 +658,7 @@ export const CasesModal = ({
653
658
  </div>
654
659
 
655
660
  <p className={styles.caseCount}>
656
- {visibleCases.length} shown of {allCases.length} total cases
661
+ {visibleCases.length} shown of {totalRegularCases} total cases
657
662
  </p>
658
663
 
659
664
  {actionNotice && (
@@ -720,12 +725,22 @@ export const CasesModal = ({
720
725
  </span>
721
726
  </div>
722
727
 
723
- <span
724
- className={`${styles.confirmationBadge} ${confirmationClass}`}
725
- aria-label={`Confirmation status: ${confirmationLabel}`}
726
- >
727
- {confirmationLabel}
728
- </span>
728
+ <div className={styles.badgeColumn}>
729
+ {caseEntry.archived && (
730
+ <span
731
+ className={`${styles.confirmationBadge} ${styles.archivedBadge}`}
732
+ aria-label="Archived case"
733
+ >
734
+ Archived
735
+ </span>
736
+ )}
737
+ <span
738
+ className={`${styles.confirmationBadge} ${confirmationClass}`}
739
+ aria-label={`Confirmation status: ${confirmationLabel}`}
740
+ >
741
+ {confirmationLabel}
742
+ </span>
743
+ </div>
729
744
  </div>
730
745
  </li>
731
746
  );
@@ -0,0 +1,102 @@
1
+ import { useCallback, type Dispatch, type SetStateAction } from 'react';
2
+ import { type AnnotationData, type FileData } from '~/types';
3
+
4
+ interface ArchiveDetailsState {
5
+ archived: boolean;
6
+ archivedAt?: string;
7
+ archivedBy?: string;
8
+ archivedByDisplay?: string;
9
+ archiveReason?: string;
10
+ }
11
+
12
+ interface UseStriaeResetHelpersProps {
13
+ setSelectedImage: Dispatch<SetStateAction<string | undefined>>;
14
+ setSelectedFilename: Dispatch<SetStateAction<string | undefined>>;
15
+ setImageId: Dispatch<SetStateAction<string | undefined>>;
16
+ setAnnotationData: Dispatch<SetStateAction<AnnotationData | null>>;
17
+ setError: Dispatch<SetStateAction<string | undefined>>;
18
+ setImageLoaded: Dispatch<SetStateAction<boolean>>;
19
+ setCurrentCase: Dispatch<SetStateAction<string>>;
20
+ setFiles: Dispatch<SetStateAction<FileData[]>>;
21
+ setActiveAnnotations: Dispatch<SetStateAction<Set<string>>>;
22
+ setIsBoxAnnotationMode: Dispatch<SetStateAction<boolean>>;
23
+ setIsReadOnlyCase: Dispatch<SetStateAction<boolean>>;
24
+ setIsReviewOnlyCase: Dispatch<SetStateAction<boolean>>;
25
+ setArchiveDetails: Dispatch<SetStateAction<ArchiveDetailsState>>;
26
+ setShowNotes: Dispatch<SetStateAction<boolean>>;
27
+ setIsAuditTrailOpen: Dispatch<SetStateAction<boolean>>;
28
+ setIsRenameCaseModalOpen: Dispatch<SetStateAction<boolean>>;
29
+ }
30
+
31
+ export const useStriaeResetHelpers = ({
32
+ setSelectedImage,
33
+ setSelectedFilename,
34
+ setImageId,
35
+ setAnnotationData,
36
+ setError,
37
+ setImageLoaded,
38
+ setCurrentCase,
39
+ setFiles,
40
+ setActiveAnnotations,
41
+ setIsBoxAnnotationMode,
42
+ setIsReadOnlyCase,
43
+ setIsReviewOnlyCase,
44
+ setArchiveDetails,
45
+ setShowNotes,
46
+ setIsAuditTrailOpen,
47
+ setIsRenameCaseModalOpen,
48
+ }: UseStriaeResetHelpersProps) => {
49
+ const clearSelectedImageState = useCallback(() => {
50
+ setSelectedImage('/clear.jpg');
51
+ setSelectedFilename(undefined);
52
+ setImageId(undefined);
53
+ setAnnotationData(null);
54
+ setError(undefined);
55
+ setImageLoaded(false);
56
+ }, [
57
+ setSelectedImage,
58
+ setSelectedFilename,
59
+ setImageId,
60
+ setAnnotationData,
61
+ setError,
62
+ setImageLoaded,
63
+ ]);
64
+
65
+ const clearCaseContextState = useCallback(() => {
66
+ setActiveAnnotations(new Set());
67
+ setIsBoxAnnotationMode(false);
68
+ setIsReadOnlyCase(false);
69
+ setIsReviewOnlyCase(false);
70
+ setArchiveDetails({ archived: false });
71
+ }, [
72
+ setActiveAnnotations,
73
+ setIsBoxAnnotationMode,
74
+ setIsReadOnlyCase,
75
+ setIsReviewOnlyCase,
76
+ setArchiveDetails,
77
+ ]);
78
+
79
+ const clearLoadedCaseState = useCallback(() => {
80
+ setCurrentCase('');
81
+ setFiles([]);
82
+ clearCaseContextState();
83
+ clearSelectedImageState();
84
+ setShowNotes(false);
85
+ setIsAuditTrailOpen(false);
86
+ setIsRenameCaseModalOpen(false);
87
+ }, [
88
+ setCurrentCase,
89
+ setFiles,
90
+ clearCaseContextState,
91
+ clearSelectedImageState,
92
+ setShowNotes,
93
+ setIsAuditTrailOpen,
94
+ setIsRenameCaseModalOpen,
95
+ ]);
96
+
97
+ return {
98
+ clearSelectedImageState,
99
+ clearCaseContextState,
100
+ clearLoadedCaseState,
101
+ };
102
+ };
@@ -17,30 +17,29 @@ import { FilesModal } from '~/components/sidebar/files/files-modal';
17
17
  import { NotesEditorModal } from '~/components/sidebar/notes/notes-editor-modal';
18
18
  import { UserAuditViewer } from '~/components/audit/user-audit-viewer';
19
19
  import { fetchUserApi } from '~/utils/api';
20
- import { resolveEarliestAnnotationTimestamp } from '~/utils/ui';
21
20
  import { type AnnotationData, type FileData } from '~/types';
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';
21
+ import { validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
24
22
  import { checkReadOnlyCaseExists, deleteReadOnlyCase } from '~/components/actions/case-review';
25
- import { canCreateCase, getLimitsDescription, getUserData } from '~/utils/data';
23
+ import { canCreateCase } from '~/utils/data';
24
+ import {
25
+ resolveEarliestAnnotationTimestamp,
26
+ CREATE_READ_ONLY_CASE_EXISTS_ERROR,
27
+ CLEAR_READ_ONLY_CASE_PARTIAL_FAILURE,
28
+ DELETE_CASE_CONFIRMATION,
29
+ DELETE_FILE_CONFIRMATION,
30
+ DELETE_CASE_FAILED,
31
+ DELETE_FILE_FAILED,
32
+ RENAME_CASE_FAILED
33
+ } from '~/utils/ui';
34
+ import { useStriaeResetHelpers } from './hooks/use-striae-reset-helpers';
35
+ import { getExportProgressLabel, loadCaseExportActions } from './utils/case-export';
36
+ import { resolveOpenCaseHelperText } from './utils/open-case-helper';
26
37
  import styles from './striae.module.css';
27
38
 
28
39
  interface StriaePage {
29
40
  user: User;
30
41
  }
31
42
 
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
-
44
43
  export const Striae = ({ user }: StriaePage) => {
45
44
  // Image and error states
46
45
  const [selectedImage, setSelectedImage] = useState<string>();
@@ -61,6 +60,7 @@ export const Striae = ({ user }: StriaePage) => {
61
60
  const [showNotes, setShowNotes] = useState(false);
62
61
  const [isUploading, setIsUploading] = useState(false);
63
62
  const [isReadOnlyCase, setIsReadOnlyCase] = useState(false);
63
+ const [isReviewOnlyCase, setIsReviewOnlyCase] = useState(false);
64
64
 
65
65
  // Annotation states
66
66
  const [activeAnnotations, setActiveAnnotations] = useState<Set<string>>(new Set());
@@ -98,24 +98,39 @@ export const Striae = ({ user }: StriaePage) => {
98
98
  archiveReason?: string;
99
99
  }>({ archived: false });
100
100
 
101
+ const {
102
+ clearSelectedImageState,
103
+ clearCaseContextState,
104
+ clearLoadedCaseState,
105
+ } = useStriaeResetHelpers({
106
+ setSelectedImage,
107
+ setSelectedFilename,
108
+ setImageId,
109
+ setAnnotationData,
110
+ setError,
111
+ setImageLoaded,
112
+ setCurrentCase,
113
+ setFiles,
114
+ setActiveAnnotations,
115
+ setIsBoxAnnotationMode,
116
+ setIsReadOnlyCase,
117
+ setIsReviewOnlyCase,
118
+ setArchiveDetails,
119
+ setShowNotes,
120
+ setIsAuditTrailOpen,
121
+ setIsRenameCaseModalOpen,
122
+ });
123
+
101
124
 
102
125
  useEffect(() => {
103
126
  // Set clear.jpg when case changes or is cleared
104
- setSelectedImage('/clear.jpg');
105
- setSelectedFilename(undefined);
106
- setImageId(undefined);
107
- setAnnotationData(null);
108
- setError(undefined);
109
- setImageLoaded(false);
127
+ clearSelectedImageState();
110
128
 
111
129
  // Reset annotation and UI states when case is cleared
112
130
  if (!currentCase) {
113
- setActiveAnnotations(new Set());
114
- setIsBoxAnnotationMode(false);
115
- setIsReadOnlyCase(false);
116
- setArchiveDetails({ archived: false });
131
+ clearCaseContextState();
117
132
  }
118
- }, [currentCase]);
133
+ }, [currentCase, clearSelectedImageState, clearCaseContextState]);
119
134
 
120
135
  // Fetch user company data when component mounts
121
136
  useEffect(() => {
@@ -154,18 +169,23 @@ export const Striae = ({ user }: StriaePage) => {
154
169
  const checkReadOnlyStatus = async () => {
155
170
  if (!currentCase || !user?.uid) {
156
171
  setIsReadOnlyCase(false);
172
+ setIsReviewOnlyCase(false);
157
173
  return;
158
174
  }
159
175
 
160
176
  try {
161
- // Check if the case data itself has isReadOnly: true
162
- const isReadOnly = await checkCaseIsReadOnly(user, currentCase);
163
- setIsReadOnlyCase(isReadOnly);
177
+ // Imported review cases are tracked in the user's read-only case list.
178
+ // This includes archived ZIP imports and distinguishes them from manually archived regular cases.
179
+ const readOnlyCaseEntry = await checkReadOnlyCaseExists(user, currentCase);
164
180
  const details = await getCaseArchiveDetails(user, currentCase);
181
+ const reviewOnly = Boolean(readOnlyCaseEntry);
182
+ setIsReviewOnlyCase(reviewOnly);
183
+ setIsReadOnlyCase(reviewOnly || details.archived);
165
184
  setArchiveDetails(details);
166
185
  } catch (error) {
167
186
  console.error('Error checking read-only status:', error);
168
187
  setIsReadOnlyCase(false);
188
+ setIsReviewOnlyCase(false);
169
189
  setArchiveDetails({ archived: false });
170
190
  }
171
191
  };
@@ -250,11 +270,7 @@ export const Striae = ({ user }: StriaePage) => {
250
270
 
251
271
  if (includeImages) {
252
272
  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';
273
+ const label = getExportProgressLabel(progress);
258
274
  onProgress?.(Math.round(progress), label);
259
275
  });
260
276
  showNotification(`Case ${exportCaseNumber} exported successfully.`, 'success');
@@ -317,7 +333,7 @@ export const Striae = ({ user }: StriaePage) => {
317
333
  try {
318
334
  const existingReadOnlyCase = await checkReadOnlyCaseExists(user, newCaseName);
319
335
  if (existingReadOnlyCase) {
320
- showNotification(`Case "${newCaseName}" already exists as a read-only review case.`, 'error');
336
+ showNotification(CREATE_READ_ONLY_CASE_EXISTS_ERROR(newCaseName), 'error');
321
337
  return;
322
338
  }
323
339
 
@@ -327,7 +343,7 @@ export const Striae = ({ user }: StriaePage) => {
327
343
  setIsRenameCaseModalOpen(false);
328
344
  showNotification(`Case renamed to ${newCaseName}.`, 'success');
329
345
  } catch (renameError) {
330
- showNotification(renameError instanceof Error ? renameError.message : 'Failed to rename case.', 'error');
346
+ showNotification(renameError instanceof Error ? renameError.message : RENAME_CASE_FAILED, 'error');
331
347
  } finally {
332
348
  setIsRenamingCase(false);
333
349
  }
@@ -339,9 +355,7 @@ export const Striae = ({ user }: StriaePage) => {
339
355
  return;
340
356
  }
341
357
 
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
- );
358
+ const confirmed = window.confirm(DELETE_CASE_CONFIRMATION(currentCase));
345
359
 
346
360
  if (!confirmed) {
347
361
  return;
@@ -350,11 +364,7 @@ export const Striae = ({ user }: StriaePage) => {
350
364
  setIsDeletingCase(true);
351
365
  try {
352
366
  const deleteResult = await deleteCase(user, currentCase);
353
- setCurrentCase('');
354
- setFiles([]);
355
- setShowNotes(false);
356
- setIsAuditTrailOpen(false);
357
- setIsRenameCaseModalOpen(false);
367
+ clearLoadedCaseState();
358
368
  if (deleteResult.missingImages.length > 0) {
359
369
  showNotification(
360
370
  `Case deleted. ${deleteResult.missingImages.length} image(s) were not found and were skipped during deletion.`,
@@ -364,7 +374,7 @@ export const Striae = ({ user }: StriaePage) => {
364
374
  showNotification('Case deleted successfully.', 'success');
365
375
  }
366
376
  } catch (deleteError) {
367
- showNotification(deleteError instanceof Error ? deleteError.message : 'Failed to delete case.', 'error');
377
+ showNotification(deleteError instanceof Error ? deleteError.message : DELETE_CASE_FAILED, 'error');
368
378
  } finally {
369
379
  setIsDeletingCase(false);
370
380
  }
@@ -383,9 +393,7 @@ export const Striae = ({ user }: StriaePage) => {
383
393
 
384
394
  const selectedFile = files.find((file) => file.id === imageId);
385
395
  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
- );
396
+ const confirmed = window.confirm(DELETE_FILE_CONFIRMATION(selectedFileName));
389
397
 
390
398
  if (!confirmed) {
391
399
  return;
@@ -396,7 +404,7 @@ export const Striae = ({ user }: StriaePage) => {
396
404
  const deleteResult = await deleteFile(user, currentCase, imageId, 'User-requested deletion via navbar file management');
397
405
  const updatedFiles = files.filter((file) => file.id !== imageId);
398
406
  setFiles(updatedFiles);
399
- handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
407
+ clearSelectedImageState();
400
408
  setShowNotes(false);
401
409
  if (deleteResult.imageMissing) {
402
410
  showNotification(
@@ -407,13 +415,18 @@ export const Striae = ({ user }: StriaePage) => {
407
415
  showNotification('File deleted successfully.', 'success');
408
416
  }
409
417
  } catch (deleteError) {
410
- showNotification(deleteError instanceof Error ? deleteError.message : 'Failed to delete file.', 'error');
418
+ showNotification(deleteError instanceof Error ? deleteError.message : DELETE_FILE_FAILED, 'error');
411
419
  } finally {
412
420
  setIsDeletingFile(false);
413
421
  }
414
422
  };
415
423
 
416
424
  const handleClearROCase = async () => {
425
+ if (!isReviewOnlyCase) {
426
+ showNotification('Only imported review cases can be cleared from workspace.', 'error');
427
+ return;
428
+ }
429
+
417
430
  if (!currentCase) {
418
431
  showNotification('No read-only case is currently loaded.', 'error');
419
432
  return;
@@ -431,15 +444,10 @@ export const Striae = ({ user }: StriaePage) => {
431
444
  try {
432
445
  const success = await deleteReadOnlyCase(user, caseToRemove);
433
446
  if (!success) {
434
- showNotification(`Failed to fully clear read-only case "${caseToRemove}". Please try again.`, 'error');
447
+ showNotification(CLEAR_READ_ONLY_CASE_PARTIAL_FAILURE(caseToRemove), 'error');
435
448
  return;
436
449
  }
437
- setCurrentCase('');
438
- setFiles([]);
439
- handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
440
- setShowNotes(false);
441
- setIsAuditTrailOpen(false);
442
- setIsRenameCaseModalOpen(false);
450
+ clearLoadedCaseState();
443
451
  showNotification(`Read-only case "${caseToRemove}" cleared.`, 'success');
444
452
  } catch (clearError) {
445
453
  showNotification(clearError instanceof Error ? clearError.message : 'Failed to clear read-only case.', 'error');
@@ -461,6 +469,7 @@ export const Striae = ({ user }: StriaePage) => {
461
469
  try {
462
470
  await archiveCase(user, currentCase, archiveReason);
463
471
  setIsReadOnlyCase(true);
472
+ setIsReviewOnlyCase(false);
464
473
  setArchiveDetails({
465
474
  archived: true,
466
475
  archivedAt: new Date().toISOString(),
@@ -506,7 +515,7 @@ export const Striae = ({ user }: StriaePage) => {
506
515
 
507
516
  const existingReadOnlyCase = await checkReadOnlyCaseExists(user, nextCaseNumber);
508
517
  if (existingReadOnlyCase) {
509
- showNotification(`Case "${nextCaseNumber}" already exists as a read-only review case.`, 'error');
518
+ showNotification(CREATE_READ_ONLY_CASE_EXISTS_ERROR(nextCaseNumber), 'error');
510
519
  return;
511
520
  }
512
521
 
@@ -531,17 +540,8 @@ export const Striae = ({ user }: StriaePage) => {
531
540
 
532
541
  const handleOpenCaseModal = async () => {
533
542
  setIsOpenCaseModalOpen(true);
534
- try {
535
- const userData = await getUserData(user);
536
- if (userData && !userData.permitted) {
537
- const limitsDescription = await getLimitsDescription(user);
538
- setOpenCaseHelperText(limitsDescription || 'Load an existing case or create a new one.');
539
- } else {
540
- setOpenCaseHelperText('Load an existing case or create a new one.');
541
- }
542
- } catch {
543
- setOpenCaseHelperText('Load an existing case or create a new one.');
544
- }
543
+ const helperText = await resolveOpenCaseHelperText(user);
544
+ setOpenCaseHelperText(helperText);
545
545
  };
546
546
 
547
547
  // Function to refresh annotation data (called when notes are saved)
@@ -566,10 +566,7 @@ export const Striae = ({ user }: StriaePage) => {
566
566
  }
567
567
  } else if (!result.caseNumber && !result.isReadOnly) {
568
568
  // Read-only case cleared - reset all UI state
569
- setCurrentCase('');
570
- setFiles([]);
571
- handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
572
- setShowNotes(false);
569
+ clearLoadedCaseState();
573
570
  }
574
571
  }
575
572
  };
@@ -746,6 +743,7 @@ export const Striae = ({ user }: StriaePage) => {
746
743
  isUploading={isUploading}
747
744
  company={userCompany}
748
745
  isReadOnly={isReadOnlyCase}
746
+ isReviewOnlyCase={isReviewOnlyCase}
749
747
  currentCase={currentCase}
750
748
  currentFileName={selectedFilename}
751
749
  isCurrentImageConfirmed={isCurrentImageConfirmed}
@@ -0,0 +1,37 @@
1
+ import type * as CaseExportActions from '~/components/actions/case-export';
2
+
3
+ export type CaseExportActionsModule = typeof CaseExportActions;
4
+
5
+ let caseExportActionsPromise: Promise<CaseExportActionsModule> | null = null;
6
+
7
+ export const loadCaseExportActions = (): Promise<CaseExportActionsModule> => {
8
+ if (!caseExportActionsPromise) {
9
+ caseExportActionsPromise = import('~/components/actions/case-export').catch((error: unknown) => {
10
+ // Clear cached failures so transient chunk/network errors can recover on retry.
11
+ caseExportActionsPromise = null;
12
+ throw error;
13
+ });
14
+ }
15
+
16
+ return caseExportActionsPromise;
17
+ };
18
+
19
+ export const getExportProgressLabel = (progress: number): string => {
20
+ if (progress < 30) {
21
+ return 'Loading case data';
22
+ }
23
+
24
+ if (progress < 50) {
25
+ return 'Preparing archive';
26
+ }
27
+
28
+ if (progress < 80) {
29
+ return 'Adding images';
30
+ }
31
+
32
+ if (progress < 96) {
33
+ return 'Finalizing';
34
+ }
35
+
36
+ return 'Downloading';
37
+ };
@@ -0,0 +1,18 @@
1
+ import type { User } from 'firebase/auth';
2
+ import { getLimitsDescription, getUserData } from '~/utils/data';
3
+
4
+ export const DEFAULT_OPEN_CASE_HELPER_TEXT = 'Load an existing case or create a new one.';
5
+
6
+ export const resolveOpenCaseHelperText = async (user: User): Promise<string> => {
7
+ try {
8
+ const userData = await getUserData(user);
9
+ if (userData && !userData.permitted) {
10
+ const limitsDescription = await getLimitsDescription(user);
11
+ return limitsDescription || DEFAULT_OPEN_CASE_HELPER_TEXT;
12
+ }
13
+
14
+ return DEFAULT_OPEN_CASE_HELPER_TEXT;
15
+ } catch {
16
+ return DEFAULT_OPEN_CASE_HELPER_TEXT;
17
+ }
18
+ };
@@ -86,7 +86,7 @@ export function filterCasesForModal(
86
86
  ): CasesModalCaseItem[] {
87
87
  const archiveFilteredCases = preferences.showArchivedOnly
88
88
  ? cases.filter((entry) => entry.archived && !entry.isReadOnly)
89
- : cases.filter((entry) => !entry.archived && !entry.isReadOnly);
89
+ : cases.filter((entry) => !entry.isReadOnly);
90
90
 
91
91
  return archiveFilteredCases.filter((entry) =>
92
92
  matchesConfirmationFilter(entry.caseNumber, preferences.confirmationFilter, caseConfirmationStatus)
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Common messages for case import, export, and case management operations.
3
+ * Centralizing messages prevents drift and ensures consistent user experience across the app.
4
+ */
5
+
6
+ // Import validation messages
7
+ export const IMPORT_FILE_TYPE_NOT_ALLOWED =
8
+ 'Only Striae case ZIP files, confirmation ZIP files, or confirmation JSON files are allowed.';
9
+
10
+ export const IMPORT_FILE_TYPE_NOT_SUPPORTED =
11
+ 'The selected file is not a supported Striae case or confirmation import package.';
12
+
13
+ // Import blocking messages
14
+ export const ARCHIVED_REGULAR_CASE_BLOCK_MESSAGE =
15
+ 'This archived case cannot be imported because the case already exists in your regular case list. Delete the regular case before importing this archive.';
16
+
17
+ // Read-only case operations
18
+ export const CREATE_READ_ONLY_CASE_EXISTS_ERROR = (caseNumber: string): string =>
19
+ `Case "${caseNumber}" already exists as a read-only review case.`;
20
+
21
+ export const CLEAR_READ_ONLY_CASE_SUCCESS = (caseNumber: string): string =>
22
+ `Removed read-only case "${caseNumber}"`;
23
+
24
+ export const CLEAR_READ_ONLY_CASE_PARTIAL_FAILURE = (caseNumber: string): string =>
25
+ `Failed to fully clear read-only case "${caseNumber}". Please try again. If this was an archived import that overlaps a regular case, verify that all case images are accessible before retrying.`;
26
+
27
+ export const CLEAR_READ_ONLY_CASE_GENERIC_ERROR =
28
+ 'Failed to clear existing case';
29
+
30
+ export const NO_READ_ONLY_CASE_LOADED =
31
+ 'No read-only case is currently loaded.';
32
+
33
+ export const CANNOT_DELETE_READ_ONLY_CASE_FILES =
34
+ 'Cannot delete files for read-only cases.';
35
+
36
+ export const READ_ONLY_CASE_CANNOT_ARCHIVE_AGAIN =
37
+ 'This case is already read-only and cannot be archived again.';
38
+
39
+ // Data integrity messages
40
+ export const DATA_INTEGRITY_VALIDATION_PASSED = '✓ Validation passed';
41
+
42
+ export const DATA_INTEGRITY_VALIDATION_FAILED = '✗ Validation failed';
43
+
44
+ export const DATA_INTEGRITY_BLOCKED_TAMPERING =
45
+ '⚠️ Import Blocked: Data hash validation failed. This file may have been tampered with or corrupted and cannot be imported.';
46
+
47
+ // Confirmation/review messages
48
+ export const CONFIRM_CASE_IMPORT =
49
+ 'Are you sure you want to import this case for review?';
50
+
51
+ // Export operation messages
52
+ export const EXPORT_FAILED = 'Export failed. Please try again.';
53
+
54
+ export const EXPORT_ALL_FAILED = 'Export all cases failed. Please try again.';
55
+
56
+ export const ENTER_CASE_NUMBER_REQUIRED = 'Please enter a case number';
57
+
58
+ // Deletion confirmation and errors
59
+ export const DELETE_CASE_CONFIRMATION = (caseNumber: string): string =>
60
+ `Are you sure you want to delete case ${caseNumber}? 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.`;
61
+
62
+ export const DELETE_FILE_CONFIRMATION = (fileName: string): string =>
63
+ `Are you sure you want to delete ${fileName}? This action cannot be undone.`;
64
+
65
+ export const DELETE_CASE_FAILED = 'Failed to delete case.';
66
+
67
+ export const DELETE_FILE_FAILED = 'Failed to delete file.';
68
+
69
+ export const RENAME_CASE_FAILED = 'Failed to rename case.';
@@ -1,2 +1,3 @@
1
1
  export * from './annotation-timestamp';
2
+ export * from './case-messages';
2
3
  export * from './style';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.3.2",
3
+ "version": "4.3.3",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -109,18 +109,18 @@
109
109
  "deploy-workers:user": "cd workers/user-worker && npm run deploy"
110
110
  },
111
111
  "dependencies": {
112
- "@react-router/cloudflare": "^7.13.1",
112
+ "@react-router/cloudflare": "^7.13.2",
113
113
  "exceljs": "^4.4.0",
114
114
  "firebase": "^12.10.0",
115
115
  "isbot": "^5.1.36",
116
116
  "jszip": "^3.10.1",
117
117
  "react": "^19.2.4",
118
118
  "react-dom": "^19.2.4",
119
- "react-router": "^7.13.1"
119
+ "react-router": "^7.13.2"
120
120
  },
121
121
  "devDependencies": {
122
- "@react-router/dev": "^7.13.1",
123
- "@react-router/fs-routes": "^7.13.1",
122
+ "@react-router/dev": "^7.13.2",
123
+ "@react-router/fs-routes": "^7.13.2",
124
124
  "@types/react": "^19.2.14",
125
125
  "@types/react-dom": "^19.2.3",
126
126
  "@typescript-eslint/eslint-plugin": "^8.57.1",
@@ -2,7 +2,7 @@
2
2
  "name": "AUDIT_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/audit-worker.ts",
5
- "compatibility_date": "2026-03-22",
5
+ "compatibility_date": "2026-03-23",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -3,7 +3,7 @@
3
3
  "name": "DATA_WORKER_NAME",
4
4
  "account_id": "ACCOUNT_ID",
5
5
  "main": "src/data-worker.ts",
6
- "compatibility_date": "2026-03-22",
6
+ "compatibility_date": "2026-03-23",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-03-22",
5
+ "compatibility_date": "2026-03-23",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "KEYS_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/keys.ts",
5
- "compatibility_date": "2026-03-22",
5
+ "compatibility_date": "2026-03-23",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-03-22",
5
+ "compatibility_date": "2026-03-23",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "USER_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/user-worker.ts",
5
- "compatibility_date": "2026-03-22",
5
+ "compatibility_date": "2026-03-23",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -1,6 +1,6 @@
1
1
  #:schema node_modules/wrangler/config-schema.json
2
2
  name = "PAGES_PROJECT_NAME"
3
- compatibility_date = "2026-03-22"
3
+ compatibility_date = "2026-03-23"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6