@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.
- package/app/components/actions/case-import/orchestrator.ts +1 -1
- package/app/components/actions/case-manage.ts +4 -8
- package/app/components/navbar/navbar.tsx +25 -12
- package/app/components/sidebar/case-import/case-import.tsx +56 -14
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +7 -6
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +9 -5
- package/app/components/sidebar/cases/cases-modal.module.css +19 -0
- package/app/components/sidebar/cases/cases-modal.tsx +23 -8
- package/app/routes/striae/hooks/use-striae-reset-helpers.ts +102 -0
- package/app/routes/striae/striae.tsx +72 -74
- package/app/routes/striae/utils/case-export.ts +37 -0
- package/app/routes/striae/utils/open-case-helper.ts +18 -0
- package/app/utils/data/case-filters.ts +1 -1
- package/app/utils/ui/case-messages.ts +69 -0
- package/app/utils/ui/index.ts +1 -0
- package/package.json +5 -5
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -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
|
|
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
|
-
//
|
|
186
|
-
if
|
|
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:
|
|
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
|
-
{
|
|
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={
|
|
157
|
-
title={
|
|
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={
|
|
170
|
-
title={
|
|
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
|
-
|
|
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
|
-
{
|
|
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 ||
|
|
257
|
+
disabled={!hasLoadedCase || disableLongRunningCaseActions || isReviewOnlyCase}
|
|
253
258
|
title={
|
|
254
|
-
|
|
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={
|
|
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 [
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
304
|
+
const hasConflict = regularCases.includes(casePreview.caseNumber);
|
|
305
|
+
setIsArchivedRegularCaseImportBlocked(hasConflict);
|
|
285
306
|
}
|
|
286
307
|
} catch {
|
|
287
308
|
if (isMounted) {
|
|
288
|
-
|
|
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
|
-
}, [
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
8
|
+
isArchivedRegularCaseImportBlocked?: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export const CasePreviewSection = ({
|
|
11
12
|
casePreview,
|
|
12
13
|
isLoadingPreview,
|
|
13
|
-
|
|
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
|
-
{
|
|
38
|
+
{isArchivedRegularCaseImportBlocked && (
|
|
38
39
|
<div className={styles.archivedRegularCaseRiskNote}>
|
|
39
|
-
|
|
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
|
-
|
|
82
|
+
<>{DATA_INTEGRITY_VALIDATION_PASSED}</>
|
|
82
83
|
) : (
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
57
|
+
{isArchivedRegularCaseImportBlocked && (
|
|
55
58
|
<div className={styles.archivedRegularCaseRiskNote}>
|
|
56
|
-
|
|
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 ?
|
|
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.
|
|
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 {
|
|
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
|
-
<
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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(
|
|
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 :
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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 :
|
|
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(
|
|
447
|
+
showNotification(CLEAR_READ_ONLY_CASE_PARTIAL_FAILURE(caseToRemove), 'error');
|
|
435
448
|
return;
|
|
436
449
|
}
|
|
437
|
-
|
|
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(
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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.
|
|
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.';
|
package/app/utils/ui/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@striae-org/striae",
|
|
3
|
-
"version": "4.3.
|
|
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.
|
|
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.
|
|
119
|
+
"react-router": "^7.13.2"
|
|
120
120
|
},
|
|
121
121
|
"devDependencies": {
|
|
122
|
-
"@react-router/dev": "^7.13.
|
|
123
|
-
"@react-router/fs-routes": "^7.13.
|
|
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",
|
package/wrangler.toml.example
CHANGED