@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.
Files changed (43) hide show
  1. package/app/components/actions/case-export/core-export.ts +11 -2
  2. package/app/components/actions/case-export/download-handlers.ts +3 -1
  3. package/app/components/canvas/canvas.module.css +1 -1
  4. package/app/components/canvas/canvas.tsx +32 -11
  5. package/app/components/colors/colors.module.css +19 -0
  6. package/app/components/colors/colors.tsx +5 -1
  7. package/app/components/icon/icons.svg +1 -1
  8. package/app/components/icon/manifest.json +1 -1
  9. package/app/components/navbar/navbar.tsx +32 -16
  10. package/app/components/sidebar/cases/case-sidebar.tsx +27 -25
  11. package/app/components/sidebar/files/files-modal.tsx +39 -15
  12. package/app/components/sidebar/notes/addl-notes-modal.tsx +9 -2
  13. package/app/components/sidebar/notes/{class-details/class-details-fields.tsx → item-details/item-details-fields.tsx} +10 -10
  14. package/app/components/sidebar/notes/{class-details/class-details-modal.tsx → item-details/item-details-modal.tsx} +20 -22
  15. package/app/components/sidebar/notes/{class-details/class-details-sections.tsx → item-details/item-details-sections.tsx} +16 -16
  16. package/app/components/sidebar/notes/{class-details/class-details-shared.ts → item-details/item-details-shared.ts} +4 -3
  17. package/app/components/sidebar/notes/{class-details/use-class-details-state.ts → item-details/use-item-details-state.ts} +4 -4
  18. package/app/components/sidebar/notes/notes-editor-form.tsx +357 -146
  19. package/app/components/sidebar/notes/notes-editor-modal.tsx +3 -0
  20. package/app/components/sidebar/notes/notes.module.css +40 -20
  21. package/app/components/sidebar/sidebar-container.tsx +1 -1
  22. package/app/components/sidebar/sidebar.tsx +3 -3
  23. package/app/components/toolbar/toolbar.tsx +5 -5
  24. package/app/hooks/useFileListPreferences.ts +22 -17
  25. package/app/routes/striae/striae.tsx +6 -13
  26. package/app/types/annotations.ts +29 -5
  27. package/app/utils/data/confirmation-summary/summary-core.ts +40 -8
  28. package/app/utils/data/file-filters.ts +39 -17
  29. package/app/utils/data/permissions.ts +123 -0
  30. package/package.json +12 -12
  31. package/workers/audit-worker/package.json +2 -2
  32. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  33. package/workers/data-worker/package.json +2 -2
  34. package/workers/data-worker/wrangler.jsonc.example +1 -1
  35. package/workers/image-worker/package.json +2 -2
  36. package/workers/image-worker/wrangler.jsonc.example +1 -1
  37. package/workers/pdf-worker/package.json +2 -2
  38. package/workers/pdf-worker/src/formats/format-striae.ts +65 -8
  39. package/workers/pdf-worker/src/report-types.ts +18 -4
  40. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  41. package/workers/user-worker/package.json +2 -2
  42. package/workers/user-worker/wrangler.jsonc.example +1 -1
  43. 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.classType ||
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
- protectForensicData
103
+ false
102
104
  );
103
105
 
104
106
  const archivePackage = await buildArchivePackage({
@@ -187,7 +187,7 @@
187
187
  letter-spacing: 0.5px;
188
188
  }
189
189
 
190
- /* Class Characteristics Display */
190
+ /* Item Type Display */
191
191
  .classCharacteristics {
192
192
  position: absolute;
193
193
  bottom: calc(100% + 0.5rem);
@@ -167,7 +167,8 @@ export const Canvas = ({
167
167
  }, [imageUrl, resetImageLoadState]);
168
168
 
169
169
  useEffect(() => {
170
- if (!activeAnnotations?.has('class') || !annotationData?.hasSubclass) {
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
- {/* Class Characteristics - Above Image */}
306
- {activeAnnotations?.has('class') && annotationData && (annotationData.customClass || annotationData.classType) && (
307
- <div className={styles.classCharacteristics}>
308
- <div className={styles.classText}>
309
- {annotationData.customClass || annotationData.classType}
310
- {annotationData.classNote && ` (${annotationData.classNote})`}
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
- </div>
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('class') && annotationData?.hasSubclass && (
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="class" viewBox="0 0 24 24">
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>
@@ -3,7 +3,7 @@
3
3
  "width": 24,
4
4
  "height": 24
5
5
  },
6
- "class": {
6
+ "item": {
7
7
  "width": 24,
8
8
  "height": 24
9
9
  },
@@ -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 isArchivedRegularReadOnly = Boolean(isReadOnly && archiveDetails?.archived && !isReviewOnlyCase);
124
- const caseExportLabel = isArchivedRegularReadOnly
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
- : undefined
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
- isConfirmed || !!selectedFileConfirmationState?.isConfirmed;
241
-
242
- const isImageNotesDisabled =
243
- !imageLoaded ||
244
- isReadOnly ||
245
- isSelectedFileConfirmed ||
246
- isUploading ||
247
- isCheckingSelectedFileConfirmation;
248
-
249
- const imageNotesTitle = isUploading
250
- ? 'Cannot edit notes while uploading'
251
- : isCheckingSelectedFileConfirmation
252
- ? 'Checking confirmation status...'
253
- : isSelectedFileConfirmed
254
- ? 'Cannot edit notes for confirmed images'
255
- : isReadOnly
256
- ? 'Cannot edit notes for read-only cases'
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 FilesModalClassTypeFilter,
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 getClassTypeLabel(classType?: FileConfirmationSummary['classType']): string {
65
- if (!classType) {
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
- return classType;
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
- setClassTypeFilter,
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.classTypeFilter !== DEFAULT_FILES_MODAL_PREFERENCES.classTypeFilter;
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).catch((err) => {
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
- }, [isOpen, currentCase, files, user, confirmationSaveVersion]);
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="classType">Class Type</option>
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-class-filter">Class Type</label>
388
+ <label htmlFor="files-item-filter">Item Type</label>
365
389
  <select
366
- id="files-class-filter"
367
- value={preferences.classTypeFilter}
390
+ id="files-item-filter"
391
+ value={preferences.itemTypeFilter}
368
392
  onChange={(event) => {
369
- setClassTypeFilter(event.target.value as FilesModalClassTypeFilter);
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 classTypeLabel = getClassTypeLabel(summary.classType);
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}>Class: {classTypeLabel}</span>
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 './class-details-shared';
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.classDetailsField} ${styles.classDetailsFieldFull}` : styles.classDetailsField;
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.classDetailsLabel}>{label}</span>
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.classDetailsInput}
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.classDetailsLabel}>{label}</span>
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.classDetailsInput}
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.classDetailsLabel}>{label}</span>
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.classDetailsInput}
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.classDetailsInput}
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.classDetailsCheckboxLabel}>
136
+ <label className={styles.itemDetailsCheckboxLabel}>
137
137
  <input
138
138
  type="checkbox"
139
139
  aria-label={label}