@striae-org/striae 6.0.1 → 6.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/actions/case-export/core-export.ts +11 -2
- package/app/components/actions/case-export/download-handlers.ts +3 -1
- package/app/components/canvas/canvas.module.css +1 -1
- package/app/components/canvas/canvas.tsx +32 -11
- package/app/components/colors/colors.module.css +19 -0
- package/app/components/colors/colors.tsx +5 -1
- package/app/components/icon/icons.svg +1 -1
- package/app/components/icon/manifest.json +1 -1
- package/app/components/navbar/navbar.tsx +32 -16
- package/app/components/sidebar/cases/case-sidebar.tsx +27 -25
- package/app/components/sidebar/files/files-modal.tsx +39 -15
- package/app/components/sidebar/notes/addl-notes-modal.tsx +9 -2
- package/app/components/sidebar/notes/{class-details/class-details-fields.tsx → item-details/item-details-fields.tsx} +10 -10
- package/app/components/sidebar/notes/{class-details/class-details-modal.tsx → item-details/item-details-modal.tsx} +20 -22
- package/app/components/sidebar/notes/{class-details/class-details-sections.tsx → item-details/item-details-sections.tsx} +16 -16
- package/app/components/sidebar/notes/{class-details/class-details-shared.ts → item-details/item-details-shared.ts} +4 -3
- package/app/components/sidebar/notes/{class-details/use-class-details-state.ts → item-details/use-item-details-state.ts} +4 -4
- package/app/components/sidebar/notes/notes-editor-form.tsx +357 -146
- package/app/components/sidebar/notes/notes-editor-modal.tsx +3 -0
- package/app/components/sidebar/notes/notes.module.css +40 -20
- package/app/components/sidebar/sidebar-container.tsx +1 -1
- package/app/components/sidebar/sidebar.tsx +3 -3
- package/app/components/toolbar/toolbar.tsx +5 -5
- package/app/hooks/useFileListPreferences.ts +22 -17
- package/app/routes/striae/striae.tsx +6 -13
- package/app/types/annotations.ts +29 -5
- package/app/utils/data/confirmation-summary/summary-core.ts +40 -8
- package/app/utils/data/file-filters.ts +39 -17
- package/app/utils/data/permissions.ts +123 -0
- package/package.json +12 -12
- package/workers/audit-worker/package.json +2 -2
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +2 -2
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +2 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +2 -2
- package/workers/pdf-worker/src/formats/format-striae.ts +65 -8
- package/workers/pdf-worker/src/report-types.ts +18 -4
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +2 -2
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -63,17 +63,26 @@ export async function exportCaseData(
|
|
|
63
63
|
try {
|
|
64
64
|
annotations = await getNotes(user, caseNumber, file.id) || undefined;
|
|
65
65
|
|
|
66
|
-
// Check if file has any annotation data beyond just defaults
|
|
66
|
+
// Check if file has any annotation data beyond just defaults.
|
|
67
|
+
// Includes left/right split fields and legacy single-value fields as fallbacks.
|
|
67
68
|
hasAnnotations = !!(annotations && (
|
|
68
69
|
annotations.additionalNotes ||
|
|
70
|
+
annotations.leftAdditionalNotes ||
|
|
71
|
+
annotations.rightAdditionalNotes ||
|
|
69
72
|
annotations.classNote ||
|
|
73
|
+
annotations.leftClassNote ||
|
|
74
|
+
annotations.rightClassNote ||
|
|
70
75
|
annotations.customClass ||
|
|
76
|
+
annotations.leftCustomClass ||
|
|
77
|
+
annotations.rightCustomClass ||
|
|
71
78
|
annotations.leftCase ||
|
|
72
79
|
annotations.rightCase ||
|
|
73
80
|
annotations.leftItem ||
|
|
74
81
|
annotations.rightItem ||
|
|
82
|
+
annotations.leftItemType ||
|
|
83
|
+
annotations.rightItemType ||
|
|
75
84
|
annotations.supportLevel ||
|
|
76
|
-
annotations.
|
|
85
|
+
annotations.itemType ||
|
|
77
86
|
(annotations.boxAnnotations && annotations.boxAnnotations.length > 0)
|
|
78
87
|
));
|
|
79
88
|
|
|
@@ -95,10 +95,12 @@ export async function downloadCaseAsZip(
|
|
|
95
95
|
exportData.metadata.exportedByName ||
|
|
96
96
|
exportData.metadata.exportedBy ||
|
|
97
97
|
'Unknown';
|
|
98
|
+
// Don't add forensic warning comment to encrypted content; it will break JSON parsing on decryption.
|
|
99
|
+
// The archive package already includes forensic metadata in README.txt and FORENSIC_MANIFEST.json.
|
|
98
100
|
const caseJsonContent = await generateJSONContent(
|
|
99
101
|
exportData,
|
|
100
102
|
options.includeUserInfo,
|
|
101
|
-
|
|
103
|
+
false
|
|
102
104
|
);
|
|
103
105
|
|
|
104
106
|
const archivePackage = await buildArchivePackage({
|
|
@@ -167,7 +167,8 @@ export const Canvas = ({
|
|
|
167
167
|
}, [imageUrl, resetImageLoadState]);
|
|
168
168
|
|
|
169
169
|
useEffect(() => {
|
|
170
|
-
|
|
170
|
+
const hasAnySubclass = annotationData?.leftHasSubclass || annotationData?.rightHasSubclass || annotationData?.hasSubclass;
|
|
171
|
+
if (!activeAnnotations?.has('item') || !hasAnySubclass) {
|
|
171
172
|
const flashResetTimer = window.setTimeout(() => {
|
|
172
173
|
clearFlashingState();
|
|
173
174
|
}, 0);
|
|
@@ -187,7 +188,7 @@ export const Canvas = ({
|
|
|
187
188
|
}, 60000);
|
|
188
189
|
|
|
189
190
|
return () => clearInterval(flashInterval);
|
|
190
|
-
}, [activeAnnotations, annotationData?.hasSubclass, clearFlashingState]);
|
|
191
|
+
}, [activeAnnotations, annotationData?.leftHasSubclass, annotationData?.rightHasSubclass, annotationData?.hasSubclass, clearFlashingState]);
|
|
191
192
|
|
|
192
193
|
const getErrorMessage = () => {
|
|
193
194
|
if (error) return error;
|
|
@@ -302,15 +303,35 @@ export const Canvas = ({
|
|
|
302
303
|
<div className={styles.imageAndNotesContainer}>
|
|
303
304
|
<div className={styles.imageContainer}>
|
|
304
305
|
<div className={styles.imageWrapper}>
|
|
305
|
-
{/*
|
|
306
|
-
{activeAnnotations?.has('
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
306
|
+
{/* Item Type - Above Image */}
|
|
307
|
+
{activeAnnotations?.has('item') && annotationData && (() => {
|
|
308
|
+
// Resolve display values from left/right fields, falling back to legacy single-set fields.
|
|
309
|
+
// When both sides are populated and differ, combine them as "Left / Right".
|
|
310
|
+
// classType is a legacy field kept for backward compat with older annotations (also handled in PDF generation).
|
|
311
|
+
const leftValue = annotationData.leftCustomClass || annotationData.leftItemType;
|
|
312
|
+
const rightValue = annotationData.rightCustomClass || annotationData.rightItemType;
|
|
313
|
+
const legacyValue = annotationData.customClass || annotationData.itemType || annotationData.classType;
|
|
314
|
+
const displayValue =
|
|
315
|
+
leftValue && rightValue && leftValue !== rightValue
|
|
316
|
+
? `${leftValue} / ${rightValue}`
|
|
317
|
+
: leftValue || rightValue || legacyValue;
|
|
318
|
+
const leftClassNote = annotationData.leftClassNote?.trim();
|
|
319
|
+
const rightClassNote = annotationData.rightClassNote?.trim();
|
|
320
|
+
const legacyClassNote = annotationData.classNote?.trim();
|
|
321
|
+
const displayClassNote =
|
|
322
|
+
leftClassNote && rightClassNote && leftClassNote !== rightClassNote
|
|
323
|
+
? `${leftClassNote} / ${rightClassNote}`
|
|
324
|
+
: leftClassNote || rightClassNote || legacyClassNote;
|
|
325
|
+
if (!displayValue) return null;
|
|
326
|
+
return (
|
|
327
|
+
<div className={styles.classCharacteristics}>
|
|
328
|
+
<div className={styles.classText}>
|
|
329
|
+
{displayValue}
|
|
330
|
+
{displayClassNote && ` (${displayClassNote})`}
|
|
331
|
+
</div>
|
|
311
332
|
</div>
|
|
312
|
-
|
|
313
|
-
)}
|
|
333
|
+
);
|
|
334
|
+
})()}
|
|
314
335
|
|
|
315
336
|
<img
|
|
316
337
|
ref={imageRef}
|
|
@@ -440,7 +461,7 @@ export const Canvas = ({
|
|
|
440
461
|
)}
|
|
441
462
|
|
|
442
463
|
{/* Subclass Warning - Bottom Right of Canvas */}
|
|
443
|
-
{activeAnnotations?.has('
|
|
464
|
+
{activeAnnotations?.has('item') && annotationData && (annotationData.leftHasSubclass || annotationData.rightHasSubclass || annotationData.hasSubclass) && (
|
|
444
465
|
<div className={`${styles.subclassWarning} ${isFlashing ? styles.flashing : ''}`}>
|
|
445
466
|
<div className={styles.subclassText}>
|
|
446
467
|
POTENTIAL SUBCLASS
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
border-color: #adb5bd;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
.toggleButton:disabled {
|
|
30
|
+
cursor: not-allowed;
|
|
31
|
+
opacity: 0.6;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
.colorWheel {
|
|
30
35
|
width: 180px;
|
|
31
36
|
height: 40px;
|
|
@@ -35,6 +40,11 @@
|
|
|
35
40
|
cursor: pointer;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
.colorWheel:disabled {
|
|
44
|
+
cursor: not-allowed;
|
|
45
|
+
opacity: 0.6;
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
.colorGrid {
|
|
39
49
|
display: grid;
|
|
40
50
|
grid-template-columns: repeat(5, 1fr);
|
|
@@ -54,6 +64,15 @@
|
|
|
54
64
|
transform: scale(1.1);
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
.colorSwatch:disabled {
|
|
68
|
+
cursor: not-allowed;
|
|
69
|
+
opacity: 0.6;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.colorSwatch:disabled:hover {
|
|
73
|
+
transform: none;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
.colorSwatch.selected {
|
|
58
77
|
border-color: #0d6efd;
|
|
59
78
|
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
|
|
@@ -4,6 +4,7 @@ import styles from './colors.module.css';
|
|
|
4
4
|
interface ColorSelectorProps {
|
|
5
5
|
selectedColor: string;
|
|
6
6
|
onColorSelect: (color: string) => void;
|
|
7
|
+
disabled?: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
interface ColorOption {
|
|
@@ -24,7 +25,7 @@ const commonColors: ColorOption[] = [
|
|
|
24
25
|
{ value: '#ffffff', label: 'White' }
|
|
25
26
|
];
|
|
26
27
|
|
|
27
|
-
export const ColorSelector = ({ selectedColor, onColorSelect }: ColorSelectorProps) => {
|
|
28
|
+
export const ColorSelector = ({ selectedColor, onColorSelect, disabled = false }: ColorSelectorProps) => {
|
|
28
29
|
const [showColorWheel, setShowColorWheel] = useState(false);
|
|
29
30
|
|
|
30
31
|
return (
|
|
@@ -34,6 +35,7 @@ export const ColorSelector = ({ selectedColor, onColorSelect }: ColorSelectorPro
|
|
|
34
35
|
<button
|
|
35
36
|
onClick={() => setShowColorWheel(!showColorWheel)}
|
|
36
37
|
className={styles.toggleButton}
|
|
38
|
+
disabled={disabled}
|
|
37
39
|
>
|
|
38
40
|
{showColorWheel ? 'Presets' : 'Color Wheel'}
|
|
39
41
|
</button>
|
|
@@ -47,6 +49,7 @@ export const ColorSelector = ({ selectedColor, onColorSelect }: ColorSelectorPro
|
|
|
47
49
|
onChange={(e) => onColorSelect(e.target.value)}
|
|
48
50
|
className={styles.colorWheel}
|
|
49
51
|
title="Choose a color"
|
|
52
|
+
disabled={disabled}
|
|
50
53
|
/>
|
|
51
54
|
</>
|
|
52
55
|
) : (
|
|
@@ -59,6 +62,7 @@ export const ColorSelector = ({ selectedColor, onColorSelect }: ColorSelectorPro
|
|
|
59
62
|
onClick={() => onColorSelect(color.value)}
|
|
60
63
|
aria-label={`Select ${color.label}`}
|
|
61
64
|
title={color.label}
|
|
65
|
+
disabled={disabled}
|
|
62
66
|
/>
|
|
63
67
|
))}
|
|
64
68
|
</div>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<symbol id="number" viewBox="0 0 24 24">
|
|
4
4
|
<path d="M1,9h24M1,17h24M10,1l-3,24M19,1l-3,24" style="stroke:#1e1e1e; stroke-linecap:round; stroke-linejoin:round; stroke-width:2px;" />
|
|
5
5
|
</symbol>
|
|
6
|
-
<symbol id="
|
|
6
|
+
<symbol id="item" viewBox="0 0 24 24">
|
|
7
7
|
<path d="M12,24c-1.66,0-3.22-.31-4.68-.95-1.46-.63-2.73-1.48-3.81-2.56s-1.94-2.35-2.57-3.81c-.63-1.46-.94-3.02-.94-4.68s.31-3.22.94-4.68,1.49-2.73,2.57-3.81,2.35-1.94,3.81-2.57,3.02-.94,4.68-.94,3.22.31,4.68.94c1.46.63,2.73,1.49,3.81,2.57s1.94,2.35,2.56,3.81c.63,1.46.95,3.02.95,4.68s-.31,3.22-.95,4.68c-.63,1.46-1.48,2.73-2.56,3.81s-2.35,1.94-3.81,2.56c-1.46.63-3.02.95-4.68.95ZM12,21.6c2.68,0,4.95-.93,6.81-2.79s2.79-4.13,2.79-6.81-.93-4.95-2.79-6.81-4.13-2.79-6.81-2.79-4.95.93-6.81,2.79-2.79,4.13-2.79,6.81.93,4.95,2.79,6.81,4.13,2.79,6.81,2.79Z" />
|
|
8
8
|
<path d="M12.29,15.89c-.5,0-.97-.09-1.4-.28-.44-.19-.82-.45-1.14-.77-.32-.32-.58-.71-.77-1.14-.19-.44-.28-.91-.28-1.4s.09-.97.28-1.4c.19-.44.45-.82.77-1.14.32-.32.7-.58,1.14-.77.44-.19.91-.28,1.4-.28s.97.09,1.4.28c.44.19.82.45,1.14.77.32.32.58.7.77,1.14.19.44.28.91.28,1.4s-.09.97-.28,1.4c-.19.44-.45.82-.77,1.14-.32.32-.71.58-1.14.77-.44.19-.91.28-1.4.28ZM12.29,15.17c.8,0,1.49-.28,2.04-.84s.84-1.24.84-2.04-.28-1.48-.84-2.04-1.24-.84-2.04-.84-1.48.28-2.04.84-.84,1.24-.84,2.04.28,1.49.84,2.04,1.24.84,2.04.84Z" />
|
|
9
9
|
</symbol>
|
|
@@ -4,7 +4,7 @@ import { SignOut } from '../actions/signout';
|
|
|
4
4
|
import { ManageProfile } from '../user/manage-profile';
|
|
5
5
|
import { CaseImport } from './case-import/case-import';
|
|
6
6
|
import { AuthContext } from '~/contexts/auth.context';
|
|
7
|
-
import { getUserData } from '~/utils/data';
|
|
7
|
+
import { getUserData, getNotesViewPermission, getNotesButtonTooltip } from '~/utils/data';
|
|
8
8
|
import { type ImportResult, type ConfirmationImportResult } from '~/types';
|
|
9
9
|
|
|
10
10
|
interface NavbarProps {
|
|
@@ -117,11 +117,29 @@ export const Navbar = ({
|
|
|
117
117
|
const disableLongRunningCaseActions = isUploading;
|
|
118
118
|
const isCaseManagementActive = true;
|
|
119
119
|
const isFileManagementActive = isFileMenuOpen || hasLoadedImage;
|
|
120
|
-
const canOpenImageNotes = hasLoadedImage && !isCurrentImageConfirmed && !isReadOnly;
|
|
121
|
-
const isImageNotesActive = canOpenImageNotes;
|
|
122
120
|
const canDeleteCurrentFile = hasLoadedImage && !isReadOnly;
|
|
123
|
-
const
|
|
124
|
-
|
|
121
|
+
const isArchivedCase = Boolean(isReadOnly && archiveDetails?.archived);
|
|
122
|
+
|
|
123
|
+
// Use centralized permission helper for notes
|
|
124
|
+
const notesPermission = getNotesViewPermission({
|
|
125
|
+
imageLoaded: hasLoadedImage,
|
|
126
|
+
isUploading: isUploading || false,
|
|
127
|
+
isCheckingConfirmation: false, // Navbar doesn't track this granularly
|
|
128
|
+
isReadOnlyCase: isReadOnly || false,
|
|
129
|
+
isArchivedCase: isArchivedCase,
|
|
130
|
+
isConfirmedImage: isCurrentImageConfirmed || false
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const imageNotesTitle = getNotesButtonTooltip(notesPermission, {
|
|
134
|
+
isReadOnlyCase: isReadOnly,
|
|
135
|
+
isArchivedCase: isArchivedCase,
|
|
136
|
+
isConfirmedImage: isCurrentImageConfirmed
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const canOpenImageNotes = notesPermission.canOpen;
|
|
140
|
+
const isImageNotesActive = canOpenImageNotes;
|
|
141
|
+
|
|
142
|
+
const caseExportLabel = isArchivedCase
|
|
125
143
|
? 'Export Archive'
|
|
126
144
|
: isReadOnly
|
|
127
145
|
? 'Export Confirmations'
|
|
@@ -184,13 +202,15 @@ export const Navbar = ({
|
|
|
184
202
|
type="button"
|
|
185
203
|
role="menuitem"
|
|
186
204
|
className={`${styles.caseMenuItem} ${styles.caseMenuItemExport}`}
|
|
187
|
-
disabled={!hasLoadedCase || disableLongRunningCaseActions}
|
|
205
|
+
disabled={!hasLoadedCase || disableLongRunningCaseActions || (isArchivedCase && isReviewOnlyCase)}
|
|
188
206
|
title={
|
|
189
207
|
!hasLoadedCase
|
|
190
208
|
? 'Load a case to export case data'
|
|
191
209
|
: disableLongRunningCaseActions
|
|
192
210
|
? 'Export is unavailable while files are uploading'
|
|
193
|
-
:
|
|
211
|
+
: isArchivedCase && isReviewOnlyCase
|
|
212
|
+
? 'Cannot export imported archive packages'
|
|
213
|
+
: undefined
|
|
194
214
|
}
|
|
195
215
|
onClick={() => {
|
|
196
216
|
onOpenCaseExport?.();
|
|
@@ -362,16 +382,12 @@ export const Navbar = ({
|
|
|
362
382
|
className={`${styles.navSectionButton} ${isImageNotesActive ? styles.navSectionButtonActive : ''}`}
|
|
363
383
|
disabled={!canOpenImageNotes}
|
|
364
384
|
aria-pressed={isImageNotesActive}
|
|
365
|
-
title={
|
|
366
|
-
!hasLoadedImage
|
|
367
|
-
? 'Load an image to enable image notes'
|
|
368
|
-
: isCurrentImageConfirmed
|
|
369
|
-
? 'Confirmed images are read-only and viewable via toolbar only'
|
|
370
|
-
: isReadOnly
|
|
371
|
-
? 'Image notes are disabled for read-only cases'
|
|
372
|
-
: undefined
|
|
373
|
-
}
|
|
385
|
+
title={imageNotesTitle}
|
|
374
386
|
onClick={() => {
|
|
387
|
+
if (!notesPermission.canOpen) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
375
391
|
onOpenImageNotes?.();
|
|
376
392
|
}}
|
|
377
393
|
>
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
import {
|
|
12
12
|
canUploadFile,
|
|
13
13
|
ensureCaseConfirmationSummary,
|
|
14
|
-
getCaseConfirmationSummary
|
|
14
|
+
getCaseConfirmationSummary,
|
|
15
|
+
getNotesViewPermission,
|
|
16
|
+
getNotesButtonTooltip
|
|
15
17
|
} from '~/utils/data';
|
|
16
18
|
import { type FileData } from '~/types';
|
|
17
19
|
|
|
@@ -26,8 +28,8 @@ interface CaseSidebarProps {
|
|
|
26
28
|
setFiles: React.Dispatch<React.SetStateAction<FileData[]>>;
|
|
27
29
|
currentCase: string | null;
|
|
28
30
|
isReadOnly?: boolean;
|
|
31
|
+
isReviewOnlyCase?: boolean;
|
|
29
32
|
isArchivedCase?: boolean;
|
|
30
|
-
isConfirmed?: boolean;
|
|
31
33
|
confirmationSaveVersion?: number;
|
|
32
34
|
selectedFileId?: string;
|
|
33
35
|
isUploading?: boolean;
|
|
@@ -47,8 +49,8 @@ export const CaseSidebar = ({
|
|
|
47
49
|
setFiles,
|
|
48
50
|
currentCase,
|
|
49
51
|
isReadOnly = false,
|
|
52
|
+
isReviewOnlyCase = false,
|
|
50
53
|
isArchivedCase = false,
|
|
51
|
-
isConfirmed = false,
|
|
52
54
|
confirmationSaveVersion = 0,
|
|
53
55
|
selectedFileId,
|
|
54
56
|
isUploading = false,
|
|
@@ -236,35 +238,35 @@ const handleImageSelect = (file: FileData) => {
|
|
|
236
238
|
selectedFileId && !selectedFileConfirmationState
|
|
237
239
|
);
|
|
238
240
|
|
|
239
|
-
const isSelectedFileConfirmed =
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
:
|
|
254
|
-
|
|
255
|
-
:
|
|
256
|
-
|
|
257
|
-
: !imageLoaded
|
|
258
|
-
? 'Select an image first'
|
|
259
|
-
: undefined;
|
|
241
|
+
const isSelectedFileConfirmed = !!selectedFileConfirmationState?.isConfirmed;
|
|
242
|
+
|
|
243
|
+
// Use centralized permission helper
|
|
244
|
+
const notesPermission = getNotesViewPermission({
|
|
245
|
+
imageLoaded,
|
|
246
|
+
isUploading,
|
|
247
|
+
isCheckingConfirmation: isCheckingSelectedFileConfirmation,
|
|
248
|
+
isReadOnlyCase: isReadOnly,
|
|
249
|
+
isArchivedCase: isArchivedCase,
|
|
250
|
+
isConfirmedImage: isSelectedFileConfirmed
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const isImageNotesDisabled = !notesPermission.canOpen;
|
|
254
|
+
const imageNotesTitle = getNotesButtonTooltip(notesPermission, {
|
|
255
|
+
isReadOnlyCase: isReadOnly,
|
|
256
|
+
isArchivedCase: isArchivedCase,
|
|
257
|
+
isConfirmedImage: isSelectedFileConfirmed
|
|
258
|
+
});
|
|
260
259
|
|
|
261
260
|
const showCaseExportButton = Boolean(currentCase && isReadOnly);
|
|
262
261
|
const caseExportButtonLabel = isArchivedCase ? 'Export Archive' : 'Export Confirmations';
|
|
262
|
+
const isImportedArchive = isArchivedCase && isReviewOnlyCase;
|
|
263
263
|
|
|
264
264
|
const exportCaseTitle = isUploading
|
|
265
265
|
? 'Cannot export while uploading'
|
|
266
266
|
: !currentCase
|
|
267
267
|
? 'Load a case first'
|
|
268
|
+
: isImportedArchive
|
|
269
|
+
? 'Cannot export imported archive packages'
|
|
268
270
|
: undefined;
|
|
269
271
|
|
|
270
272
|
return (
|
|
@@ -389,7 +391,7 @@ return (
|
|
|
389
391
|
<button
|
|
390
392
|
className={styles.confirmationExportButton}
|
|
391
393
|
onClick={onOpenCaseExport}
|
|
392
|
-
disabled={isUploading || !currentCase}
|
|
394
|
+
disabled={isUploading || !currentCase || isImportedArchive}
|
|
393
395
|
title={exportCaseTitle}
|
|
394
396
|
>
|
|
395
397
|
{caseExportButtonLabel}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
type FilesModalSortBy,
|
|
11
11
|
type FilesModalConfirmationFilter,
|
|
12
|
-
type
|
|
12
|
+
type FilesModalItemTypeFilter,
|
|
13
13
|
getFilesForModal,
|
|
14
14
|
} from '~/utils/data/file-filters';
|
|
15
15
|
import { deleteFile } from '~/components/actions/image-manage';
|
|
@@ -61,12 +61,24 @@ function formatDate(dateString: string): string {
|
|
|
61
61
|
return new Date(parsed).toLocaleDateString();
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function
|
|
65
|
-
|
|
64
|
+
function getItemTypeLabel(summary: FileConfirmationSummary): string {
|
|
65
|
+
const itemTypes = [
|
|
66
|
+
summary.leftItemType,
|
|
67
|
+
summary.rightItemType,
|
|
68
|
+
summary.itemType,
|
|
69
|
+
].filter((value): value is NonNullable<FileConfirmationSummary['itemType']> => Boolean(value));
|
|
70
|
+
|
|
71
|
+
const uniqueItemTypes = Array.from(new Set(itemTypes));
|
|
72
|
+
|
|
73
|
+
if (uniqueItemTypes.length === 0) {
|
|
66
74
|
return 'Unset';
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
|
|
77
|
+
if (uniqueItemTypes.length === 1) {
|
|
78
|
+
return uniqueItemTypes[0];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return `${uniqueItemTypes[0]} / ${uniqueItemTypes[1]}`;
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
function getConfirmationLabel(summary: FileConfirmationSummary): string {
|
|
@@ -110,7 +122,7 @@ export const FilesModal = ({
|
|
|
110
122
|
preferences,
|
|
111
123
|
setSortBy,
|
|
112
124
|
setConfirmationFilter,
|
|
113
|
-
|
|
125
|
+
setItemTypeFilter,
|
|
114
126
|
resetPreferences,
|
|
115
127
|
} = useFileListPreferences();
|
|
116
128
|
const {
|
|
@@ -125,7 +137,7 @@ export const FilesModal = ({
|
|
|
125
137
|
const hasCustomPreferences =
|
|
126
138
|
preferences.sortBy !== DEFAULT_FILES_MODAL_PREFERENCES.sortBy ||
|
|
127
139
|
preferences.confirmationFilter !== DEFAULT_FILES_MODAL_PREFERENCES.confirmationFilter ||
|
|
128
|
-
preferences.
|
|
140
|
+
preferences.itemTypeFilter !== DEFAULT_FILES_MODAL_PREFERENCES.itemTypeFilter;
|
|
129
141
|
|
|
130
142
|
const existingFileIdSet = useMemo(
|
|
131
143
|
() => new Set(files.map((file) => file.id)),
|
|
@@ -162,6 +174,9 @@ export const FilesModal = ({
|
|
|
162
174
|
(effectiveCurrentPage + 1) * FILES_PER_PAGE
|
|
163
175
|
);
|
|
164
176
|
|
|
177
|
+
const shouldForceItemTypeSummaryRefresh =
|
|
178
|
+
preferences.sortBy === 'itemType' || preferences.itemTypeFilter !== 'all';
|
|
179
|
+
|
|
165
180
|
useEffect(() => {
|
|
166
181
|
let isCancelled = false;
|
|
167
182
|
|
|
@@ -173,7 +188,9 @@ export const FilesModal = ({
|
|
|
173
188
|
return;
|
|
174
189
|
}
|
|
175
190
|
|
|
176
|
-
const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files
|
|
191
|
+
const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files, {
|
|
192
|
+
forceRefresh: shouldForceItemTypeSummaryRefresh,
|
|
193
|
+
}).catch((err) => {
|
|
177
194
|
console.error(`Error fetching confirmation summary for case ${currentCase}:`, err);
|
|
178
195
|
return null;
|
|
179
196
|
});
|
|
@@ -190,7 +207,14 @@ export const FilesModal = ({
|
|
|
190
207
|
return () => {
|
|
191
208
|
isCancelled = true;
|
|
192
209
|
};
|
|
193
|
-
}, [
|
|
210
|
+
}, [
|
|
211
|
+
isOpen,
|
|
212
|
+
currentCase,
|
|
213
|
+
files,
|
|
214
|
+
user,
|
|
215
|
+
confirmationSaveVersion,
|
|
216
|
+
shouldForceItemTypeSummaryRefresh,
|
|
217
|
+
]);
|
|
194
218
|
|
|
195
219
|
const toggleDeleteSelection = (fileId: string) => {
|
|
196
220
|
setDeleteSelectedFileIds((previous) => {
|
|
@@ -339,7 +363,7 @@ export const FilesModal = ({
|
|
|
339
363
|
<option value="recent">Date Uploaded</option>
|
|
340
364
|
<option value="filename">File Name</option>
|
|
341
365
|
<option value="confirmation">Confirmation Status</option>
|
|
342
|
-
<option value="
|
|
366
|
+
<option value="itemType">Item Type</option>
|
|
343
367
|
</select>
|
|
344
368
|
</div>
|
|
345
369
|
|
|
@@ -361,12 +385,12 @@ export const FilesModal = ({
|
|
|
361
385
|
</div>
|
|
362
386
|
|
|
363
387
|
<div className={styles.controlGroup}>
|
|
364
|
-
<label htmlFor="files-
|
|
388
|
+
<label htmlFor="files-item-filter">Item Type</label>
|
|
365
389
|
<select
|
|
366
|
-
id="files-
|
|
367
|
-
value={preferences.
|
|
390
|
+
id="files-item-filter"
|
|
391
|
+
value={preferences.itemTypeFilter}
|
|
368
392
|
onChange={(event) => {
|
|
369
|
-
|
|
393
|
+
setItemTypeFilter(event.target.value as FilesModalItemTypeFilter);
|
|
370
394
|
setCurrentPage(0);
|
|
371
395
|
}}
|
|
372
396
|
>
|
|
@@ -434,7 +458,7 @@ export const FilesModal = ({
|
|
|
434
458
|
const isOpenSelected = effectiveOpenSelectedFileId === file.id;
|
|
435
459
|
const isDeleteSelected = effectiveDeleteSelectedFileIds.has(file.id);
|
|
436
460
|
const confirmationLabel = getConfirmationLabel(summary);
|
|
437
|
-
const
|
|
461
|
+
const itemTypeLabel = getItemTypeLabel(summary);
|
|
438
462
|
|
|
439
463
|
let confirmationClass = '';
|
|
440
464
|
if (summary.includeConfirmation) {
|
|
@@ -472,7 +496,7 @@ export const FilesModal = ({
|
|
|
472
496
|
</div>
|
|
473
497
|
<div className={styles.fileMetaRow}>
|
|
474
498
|
<span className={styles.fileDate}>Uploaded: {formatDate(file.uploadedAt)}</span>
|
|
475
|
-
<span className={styles.classTypeBadge}>
|
|
499
|
+
<span className={styles.classTypeBadge}>Item: {itemTypeLabel}</span>
|
|
476
500
|
</div>
|
|
477
501
|
</div>
|
|
478
502
|
|
|
@@ -7,10 +7,11 @@ interface AddlNotesModalProps {
|
|
|
7
7
|
onClose: () => void;
|
|
8
8
|
notes: string;
|
|
9
9
|
onSave: (notes: string) => void;
|
|
10
|
+
isReadOnly?: boolean;
|
|
10
11
|
showNotification?: (message: string, type: 'success' | 'error' | 'warning') => void;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export const AddlNotesModal = ({ isOpen, onClose, notes, onSave, showNotification }: AddlNotesModalProps) => {
|
|
14
|
+
export const AddlNotesModal = ({ isOpen, onClose, notes, onSave, isReadOnly = false, showNotification }: AddlNotesModalProps) => {
|
|
14
15
|
const [tempNotes, setTempNotes] = useState(notes);
|
|
15
16
|
const [isSaving, setIsSaving] = useState(false);
|
|
16
17
|
|
|
@@ -31,6 +32,11 @@ export const AddlNotesModal = ({ isOpen, onClose, notes, onSave, showNotificatio
|
|
|
31
32
|
if (!isOpen) return null;
|
|
32
33
|
|
|
33
34
|
const handleSave = async () => {
|
|
35
|
+
if (isReadOnly) {
|
|
36
|
+
showNotification?.('This case is read-only. Notes cannot be modified.', 'error');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
setIsSaving(true);
|
|
35
41
|
try {
|
|
36
42
|
await Promise.resolve(onSave(tempNotes));
|
|
@@ -58,12 +64,13 @@ export const AddlNotesModal = ({ isOpen, onClose, notes, onSave, showNotificatio
|
|
|
58
64
|
onChange={(e) => setTempNotes(e.target.value)}
|
|
59
65
|
className={styles.modalTextarea}
|
|
60
66
|
placeholder="Enter additional notes..."
|
|
67
|
+
disabled={isReadOnly}
|
|
61
68
|
/>
|
|
62
69
|
<div className={styles.modalButtons}>
|
|
63
70
|
<button
|
|
64
71
|
onClick={handleSave}
|
|
65
72
|
className={styles.saveButton}
|
|
66
|
-
disabled={isSaving}
|
|
73
|
+
disabled={isSaving || isReadOnly}
|
|
67
74
|
aria-busy={isSaving}
|
|
68
75
|
>
|
|
69
76
|
{isSaving ? 'Saving...' : 'Save'}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import styles from '../notes.module.css';
|
|
3
|
-
import { CUSTOM, handleSelectWithCustom } from './
|
|
3
|
+
import { CUSTOM, handleSelectWithCustom } from './item-details-shared';
|
|
4
4
|
|
|
5
5
|
interface BaseFieldProps {
|
|
6
6
|
label: string;
|
|
@@ -37,7 +37,7 @@ interface CheckboxFieldProps {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const fieldClassName = (fullWidth = false): string =>
|
|
40
|
-
fullWidth ? `${styles.
|
|
40
|
+
fullWidth ? `${styles.itemDetailsField} ${styles.itemDetailsFieldFull}` : styles.itemDetailsField;
|
|
41
41
|
|
|
42
42
|
export const TextField = ({
|
|
43
43
|
label,
|
|
@@ -50,14 +50,14 @@ export const TextField = ({
|
|
|
50
50
|
min,
|
|
51
51
|
}: TextFieldProps) => (
|
|
52
52
|
<div className={fieldClassName(fullWidth)}>
|
|
53
|
-
<span className={styles.
|
|
53
|
+
<span className={styles.itemDetailsLabel}>{label}</span>
|
|
54
54
|
<input
|
|
55
55
|
type={type}
|
|
56
56
|
min={min}
|
|
57
57
|
aria-label={label}
|
|
58
58
|
value={value}
|
|
59
59
|
onChange={(event) => onChange(event.target.value)}
|
|
60
|
-
className={styles.
|
|
60
|
+
className={styles.itemDetailsInput}
|
|
61
61
|
disabled={disabled}
|
|
62
62
|
placeholder={placeholder}
|
|
63
63
|
/>
|
|
@@ -74,12 +74,12 @@ export const SelectField = ({
|
|
|
74
74
|
fullWidth = false,
|
|
75
75
|
}: SelectFieldProps) => (
|
|
76
76
|
<div className={fieldClassName(fullWidth)}>
|
|
77
|
-
<span className={styles.
|
|
77
|
+
<span className={styles.itemDetailsLabel}>{label}</span>
|
|
78
78
|
<select
|
|
79
79
|
aria-label={label}
|
|
80
80
|
value={value}
|
|
81
81
|
onChange={(event) => onChange(event.target.value)}
|
|
82
|
-
className={styles.
|
|
82
|
+
className={styles.itemDetailsInput}
|
|
83
83
|
disabled={disabled}
|
|
84
84
|
>
|
|
85
85
|
<option value="">{placeholder}</option>
|
|
@@ -101,12 +101,12 @@ export const SelectWithCustomField = ({
|
|
|
101
101
|
fullWidth = false,
|
|
102
102
|
}: SelectWithCustomFieldProps) => (
|
|
103
103
|
<div className={fieldClassName(fullWidth)}>
|
|
104
|
-
<span className={styles.
|
|
104
|
+
<span className={styles.itemDetailsLabel}>{label}</span>
|
|
105
105
|
<select
|
|
106
106
|
aria-label={label}
|
|
107
107
|
value={isCustom ? CUSTOM : value}
|
|
108
108
|
onChange={(event) => handleSelectWithCustom(event.target.value, onChange, onCustomChange)}
|
|
109
|
-
className={styles.
|
|
109
|
+
className={styles.itemDetailsInput}
|
|
110
110
|
disabled={disabled}
|
|
111
111
|
>
|
|
112
112
|
<option value="">{placeholder}</option>
|
|
@@ -119,7 +119,7 @@ export const SelectWithCustomField = ({
|
|
|
119
119
|
aria-label={label}
|
|
120
120
|
value={value}
|
|
121
121
|
onChange={(event) => onChange(event.target.value)}
|
|
122
|
-
className={styles.
|
|
122
|
+
className={styles.itemDetailsInput}
|
|
123
123
|
disabled={disabled}
|
|
124
124
|
placeholder={customPlaceholder}
|
|
125
125
|
/>
|
|
@@ -133,7 +133,7 @@ export const CheckboxField = ({
|
|
|
133
133
|
onChange,
|
|
134
134
|
disabled,
|
|
135
135
|
}: CheckboxFieldProps) => (
|
|
136
|
-
<label className={styles.
|
|
136
|
+
<label className={styles.itemDetailsCheckboxLabel}>
|
|
137
137
|
<input
|
|
138
138
|
type="checkbox"
|
|
139
139
|
aria-label={label}
|