@striae-org/striae 6.0.1 → 6.1.0

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 (40) 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/icon/icons.svg +1 -1
  6. package/app/components/icon/manifest.json +1 -1
  7. package/app/components/navbar/navbar.tsx +10 -9
  8. package/app/components/sidebar/cases/case-sidebar.tsx +6 -1
  9. package/app/components/sidebar/files/files-modal.tsx +39 -15
  10. package/app/components/sidebar/notes/addl-notes-modal.tsx +9 -2
  11. package/app/components/sidebar/notes/{class-details/class-details-fields.tsx → item-details/item-details-fields.tsx} +10 -10
  12. package/app/components/sidebar/notes/{class-details/class-details-modal.tsx → item-details/item-details-modal.tsx} +20 -22
  13. package/app/components/sidebar/notes/{class-details/class-details-sections.tsx → item-details/item-details-sections.tsx} +16 -16
  14. package/app/components/sidebar/notes/{class-details/class-details-shared.ts → item-details/item-details-shared.ts} +4 -3
  15. package/app/components/sidebar/notes/{class-details/use-class-details-state.ts → item-details/use-item-details-state.ts} +4 -4
  16. package/app/components/sidebar/notes/notes-editor-form.tsx +333 -124
  17. package/app/components/sidebar/notes/notes-editor-modal.tsx +3 -0
  18. package/app/components/sidebar/notes/notes.module.css +40 -20
  19. package/app/components/sidebar/sidebar-container.tsx +1 -0
  20. package/app/components/sidebar/sidebar.tsx +3 -0
  21. package/app/components/toolbar/toolbar.tsx +5 -5
  22. package/app/hooks/useFileListPreferences.ts +22 -17
  23. package/app/routes/striae/striae.tsx +4 -10
  24. package/app/types/annotations.ts +28 -5
  25. package/app/utils/data/confirmation-summary/summary-core.ts +40 -8
  26. package/app/utils/data/file-filters.ts +39 -17
  27. package/package.json +139 -139
  28. package/workers/audit-worker/package.json +2 -2
  29. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  30. package/workers/data-worker/package.json +2 -2
  31. package/workers/data-worker/wrangler.jsonc.example +1 -1
  32. package/workers/image-worker/package.json +2 -2
  33. package/workers/image-worker/wrangler.jsonc.example +1 -1
  34. package/workers/pdf-worker/package.json +2 -2
  35. package/workers/pdf-worker/src/formats/format-striae.ts +65 -8
  36. package/workers/pdf-worker/src/report-types.ts +13 -1
  37. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  38. package/workers/user-worker/package.json +2 -2
  39. package/workers/user-worker/wrangler.jsonc.example +1 -1
  40. package/wrangler.toml.example +1 -1
@@ -13,6 +13,7 @@ interface NotesEditorModalProps {
13
13
  originalFileName?: string;
14
14
  onAnnotationRefresh?: () => void;
15
15
  isUploading?: boolean;
16
+ isReadOnly?: boolean;
16
17
  showNotification?: (message: string, type: 'success' | 'error' | 'warning') => void;
17
18
  }
18
19
 
@@ -25,6 +26,7 @@ export const NotesEditorModal = ({
25
26
  originalFileName,
26
27
  onAnnotationRefresh,
27
28
  isUploading = false,
29
+ isReadOnly = false,
28
30
  showNotification,
29
31
  }: NotesEditorModalProps) => {
30
32
  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
@@ -186,6 +188,7 @@ export const NotesEditorModal = ({
186
188
  onAnnotationRefresh={onAnnotationRefresh}
187
189
  originalFileName={originalFileName}
188
190
  isUploading={isUploading}
191
+ isReadOnly={isReadOnly}
189
192
  showNotification={showNotification}
190
193
  onDirtyChange={handleDirtyChange}
191
194
  onRegisterSaveHandler={handleRegisterSaveHandler}
@@ -54,6 +54,26 @@
54
54
  padding-top: 1.25rem;
55
55
  }
56
56
 
57
+ .itemSelectorRow {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 0.5rem;
61
+ margin-bottom: 1rem;
62
+ }
63
+
64
+ .itemSelectorRow label {
65
+ font-weight: 500;
66
+ font-size: 0.875rem;
67
+ }
68
+
69
+ .itemSelectorRow select {
70
+ padding: 0.5rem 0.75rem;
71
+ border: 1px solid #ced4da;
72
+ border-radius: 0.25rem;
73
+ background-color: white;
74
+ font-size: 0.875rem;
75
+ }
76
+
57
77
  @media (max-width: 980px) {
58
78
  .editorLayout .caseNumbers,
59
79
  .compactSectionGrid {
@@ -249,14 +269,14 @@ textarea:focus {
249
269
 
250
270
  /* Class Details Panel — replaces placeholder in right column */
251
271
 
252
- .classDetailsPanel {
272
+ .itemDetailsPanel {
253
273
  display: flex;
254
274
  flex-direction: column;
255
275
  gap: 0.75rem;
256
276
  min-height: 150px;
257
277
  }
258
278
 
259
- .classDetailsButton {
279
+ .itemDetailsButton {
260
280
  width: 100%;
261
281
  padding: 0.65rem 1rem;
262
282
  background: transparent;
@@ -270,18 +290,18 @@ textarea:focus {
270
290
  text-align: center;
271
291
  }
272
292
 
273
- .classDetailsButton:hover:not(:disabled) {
293
+ .itemDetailsButton:hover:not(:disabled) {
274
294
  background-color: color-mix(in lab, var(--primary) 10%, transparent);
275
295
  }
276
296
 
277
- .classDetailsButton:disabled {
297
+ .itemDetailsButton:disabled {
278
298
  opacity: 0.5;
279
299
  cursor: not-allowed;
280
300
  }
281
301
 
282
302
  /* Class Details Modal */
283
303
 
284
- .classDetailsModal {
304
+ .itemDetailsModal {
285
305
  max-width: 680px;
286
306
  max-height: 80vh;
287
307
  display: flex;
@@ -289,7 +309,7 @@ textarea:focus {
289
309
  gap: 1rem;
290
310
  }
291
311
 
292
- .classDetailsContent {
312
+ .itemDetailsContent {
293
313
  overflow-y: auto;
294
314
  flex: 1;
295
315
  min-height: 0;
@@ -301,13 +321,13 @@ textarea:focus {
301
321
  scrollbar-width: thin;
302
322
  }
303
323
 
304
- .classDetailsSection {
324
+ .itemDetailsSection {
305
325
  display: flex;
306
326
  flex-direction: column;
307
327
  gap: 1rem;
308
328
  }
309
329
 
310
- .classDetailsSectionHeader {
330
+ .itemDetailsSectionHeader {
311
331
  margin: 0;
312
332
  font-size: 0.95rem;
313
333
  font-weight: 700;
@@ -316,29 +336,29 @@ textarea:focus {
316
336
  border-bottom: 1.5px solid #dee2e6;
317
337
  }
318
338
 
319
- .classDetailsFieldGrid {
339
+ .itemDetailsFieldGrid {
320
340
  display: grid;
321
341
  grid-template-columns: 1fr 1fr;
322
342
  gap: 0.85rem 1.25rem;
323
343
  }
324
344
 
325
- .classDetailsField {
345
+ .itemDetailsField {
326
346
  display: flex;
327
347
  flex-direction: column;
328
348
  gap: 0.25rem;
329
349
  }
330
350
 
331
- .classDetailsFieldFull {
351
+ .itemDetailsFieldFull {
332
352
  grid-column: 1 / -1;
333
353
  }
334
354
 
335
- .classDetailsLabel {
355
+ .itemDetailsLabel {
336
356
  font-size: 0.8rem;
337
357
  font-weight: 600;
338
358
  color: #495057;
339
359
  }
340
360
 
341
- .classDetailsInput {
361
+ .itemDetailsInput {
342
362
  padding: 0.5rem 0.65rem;
343
363
  border: 1.5px solid #ced4da;
344
364
  border-radius: 6px;
@@ -348,23 +368,23 @@ textarea:focus {
348
368
  box-sizing: border-box;
349
369
  }
350
370
 
351
- .classDetailsInput:focus {
371
+ .itemDetailsInput:focus {
352
372
  border-color: var(--primary);
353
373
  outline: none;
354
374
  }
355
375
 
356
- .classDetailsInput:disabled {
376
+ .itemDetailsInput:disabled {
357
377
  background-color: #f8f9fa;
358
378
  cursor: not-allowed;
359
379
  }
360
380
 
361
- .classDetailsCheckboxGroup {
381
+ .itemDetailsCheckboxGroup {
362
382
  display: flex;
363
383
  flex-wrap: wrap;
364
384
  gap: 0.4rem 1.25rem;
365
385
  }
366
386
 
367
- .classDetailsCheckboxLabel {
387
+ .itemDetailsCheckboxLabel {
368
388
  display: flex;
369
389
  align-items: center;
370
390
  gap: 0.4rem;
@@ -373,11 +393,11 @@ textarea:focus {
373
393
  cursor: pointer;
374
394
  }
375
395
 
376
- .classDetailsCheckboxLabel input[type="checkbox"] {
396
+ .itemDetailsCheckboxLabel input[type="checkbox"] {
377
397
  cursor: pointer;
378
398
  }
379
399
 
380
- .classDetailsCheckboxLabel input[type="checkbox"]:disabled {
400
+ .itemDetailsCheckboxLabel input[type="checkbox"]:disabled {
381
401
  cursor: not-allowed;
382
402
  }
383
403
 
@@ -697,7 +717,7 @@ textarea:focus {
697
717
  justify-content: center;
698
718
  }
699
719
 
700
- .classDetailsModalButtons .classDetailsModalAction {
720
+ .itemDetailsModalButtons .itemDetailsModalAction {
701
721
  min-height: 42px;
702
722
  margin: 0;
703
723
  display: inline-flex;
@@ -24,6 +24,7 @@ interface SidebarContainerProps {
24
24
  setShowNotes: (show: boolean) => void;
25
25
  onAnnotationRefresh?: () => void;
26
26
  isReadOnly?: boolean;
27
+ isReviewOnlyCase?: boolean;
27
28
  isArchivedCase?: boolean;
28
29
  isConfirmed?: boolean;
29
30
  confirmationSaveVersion?: number;
@@ -20,6 +20,7 @@ interface SidebarProps {
20
20
  setShowNotes: (show: boolean) => void;
21
21
  onAnnotationRefresh?: () => void;
22
22
  isReadOnly?: boolean;
23
+ isReviewOnlyCase?: boolean;
23
24
  isArchivedCase?: boolean;
24
25
  isConfirmed?: boolean;
25
26
  confirmationSaveVersion?: number;
@@ -40,6 +41,7 @@ export const Sidebar = ({
40
41
  setFiles,
41
42
  setShowNotes,
42
43
  isReadOnly = false,
44
+ isReviewOnlyCase = false,
43
45
  isArchivedCase = false,
44
46
  isConfirmed = false,
45
47
  confirmationSaveVersion = 0,
@@ -89,6 +91,7 @@ export const Sidebar = ({
89
91
  setFiles={setFiles}
90
92
  onNotesClick={() => setShowNotes(true)}
91
93
  isReadOnly={isReadOnly}
94
+ isReviewOnlyCase={isReviewOnlyCase}
92
95
  isArchivedCase={isArchivedCase}
93
96
  isConfirmed={isConfirmed}
94
97
  confirmationSaveVersion={confirmationSaveVersion}
@@ -3,7 +3,7 @@ import { Button } from '../button/button';
3
3
  import { ToolbarColorSelector } from './toolbar-color-selector';
4
4
  import styles from './toolbar.module.css';
5
5
 
6
- type ToolId = 'number' | 'class' | 'index' | 'id' | 'notes' | 'print' | 'visibility' | 'box';
6
+ type ToolId = 'number' | 'item' | 'index' | 'id' | 'notes' | 'print' | 'visibility' | 'box';
7
7
 
8
8
  interface ToolbarProps {
9
9
  onToolSelect?: (toolId: ToolId, active: boolean) => void;
@@ -125,10 +125,10 @@ export const Toolbar = ({
125
125
  ariaLabel="Case & Item Numbers"
126
126
  />
127
127
  <Button
128
- iconId="class"
129
- isActive={activeTools.has('class')}
130
- onClick={() => handleToolClick('class')}
131
- ariaLabel="Class Characteristics"
128
+ iconId="item"
129
+ isActive={activeTools.has('item')}
130
+ onClick={() => handleToolClick('item')}
131
+ ariaLabel="Item Type"
132
132
  />
133
133
  <Button
134
134
  iconId="index"
@@ -3,7 +3,7 @@ import {
3
3
  type FilesModalPreferences,
4
4
  type FilesModalSortBy,
5
5
  type FilesModalConfirmationFilter,
6
- type FilesModalClassTypeFilter,
6
+ type FilesModalItemTypeFilter,
7
7
  } from '~/utils/data/file-filters';
8
8
 
9
9
  const FILES_MODAL_PREFERENCES_STORAGE_KEY = 'striae.filesModal.preferences';
@@ -11,7 +11,7 @@ const FILES_MODAL_PREFERENCES_STORAGE_KEY = 'striae.filesModal.preferences';
11
11
  export const DEFAULT_FILES_MODAL_PREFERENCES: FilesModalPreferences = {
12
12
  sortBy: 'recent',
13
13
  confirmationFilter: 'all',
14
- classTypeFilter: 'all',
14
+ itemTypeFilter: 'all',
15
15
  };
16
16
 
17
17
  function parseStoredPreferences(value: string | null): FilesModalPreferences {
@@ -25,7 +25,7 @@ function parseStoredPreferences(value: string | null): FilesModalPreferences {
25
25
  const sortBy: FilesModalSortBy =
26
26
  parsed.sortBy === 'filename' ||
27
27
  parsed.sortBy === 'confirmation' ||
28
- parsed.sortBy === 'classType' ||
28
+ parsed.sortBy === 'itemType' ||
29
29
  parsed.sortBy === 'recent'
30
30
  ? parsed.sortBy
31
31
  : DEFAULT_FILES_MODAL_PREFERENCES.sortBy;
@@ -38,21 +38,26 @@ function parseStoredPreferences(value: string | null): FilesModalPreferences {
38
38
  ? parsed.confirmationFilter
39
39
  : DEFAULT_FILES_MODAL_PREFERENCES.confirmationFilter;
40
40
 
41
- const classTypeFilter: FilesModalClassTypeFilter =
42
- parsed.classTypeFilter === 'Bullet' ||
43
- parsed.classTypeFilter === 'Cartridge Case' ||
44
- parsed.classTypeFilter === 'Shotshell' ||
45
- parsed.classTypeFilter === 'Other' ||
46
- parsed.classTypeFilter === 'all'
47
- ? parsed.classTypeFilter
48
- : parsed.classTypeFilter === 'unset'
49
- ? 'Other'
50
- : DEFAULT_FILES_MODAL_PREFERENCES.classTypeFilter;
41
+ // Support both new 'itemTypeFilter' and legacy 'classTypeFilter' properties
42
+ const itemTypeFilter: FilesModalItemTypeFilter =
43
+ parsed.itemTypeFilter === 'Bullet' ||
44
+ parsed.itemTypeFilter === 'Cartridge Case' ||
45
+ parsed.itemTypeFilter === 'Shotshell' ||
46
+ parsed.itemTypeFilter === 'Other' ||
47
+ parsed.itemTypeFilter === 'all'
48
+ ? parsed.itemTypeFilter
49
+ : parsed.classTypeFilter === 'Bullet' ||
50
+ parsed.classTypeFilter === 'Cartridge Case' ||
51
+ parsed.classTypeFilter === 'Shotshell' ||
52
+ parsed.classTypeFilter === 'Other' ||
53
+ parsed.classTypeFilter === 'all'
54
+ ? parsed.classTypeFilter
55
+ : 'all';
51
56
 
52
57
  return {
53
58
  sortBy,
54
59
  confirmationFilter,
55
- classTypeFilter,
60
+ itemTypeFilter,
56
61
  };
57
62
  } catch {
58
63
  return DEFAULT_FILES_MODAL_PREFERENCES;
@@ -88,8 +93,8 @@ export function useFileListPreferences() {
88
93
  setPreferences((current) => ({ ...current, confirmationFilter }));
89
94
  };
90
95
 
91
- const setClassTypeFilter = (classTypeFilter: FilesModalClassTypeFilter) => {
92
- setPreferences((current) => ({ ...current, classTypeFilter }));
96
+ const setItemTypeFilter = (itemTypeFilter: FilesModalItemTypeFilter) => {
97
+ setPreferences((current) => ({ ...current, itemTypeFilter }));
93
98
  };
94
99
 
95
100
  const resetPreferences = () => {
@@ -100,7 +105,7 @@ export function useFileListPreferences() {
100
105
  preferences,
101
106
  setSortBy,
102
107
  setConfirmationFilter,
103
- setClassTypeFilter,
108
+ setItemTypeFilter,
104
109
  resetPreferences,
105
110
  };
106
111
  }
@@ -677,24 +677,16 @@ export const Striae = ({ user }: StriaePage) => {
677
677
  const notes = await getNotes(user, currentCase, imageId);
678
678
  if (notes) {
679
679
  setAnnotationData({
680
+ ...notes,
680
681
  leftCase: notes.leftCase || '',
681
682
  rightCase: notes.rightCase || '',
682
683
  leftItem: notes.leftItem || '',
683
684
  rightItem: notes.rightItem || '',
684
685
  caseFontColor: notes.caseFontColor || '#FFDE21',
685
- classType: notes.classType || 'Other',
686
- customClass: notes.customClass,
687
- classNote: notes.classNote, // Optional - pass as-is
688
686
  indexType: notes.indexType || 'number',
689
- indexNumber: notes.indexNumber,
690
- indexColor: notes.indexColor,
691
687
  supportLevel: notes.supportLevel || 'Inconclusive',
692
- hasSubclass: notes.hasSubclass,
693
- includeConfirmation: notes.includeConfirmation ?? false, // Required
694
- confirmationData: notes.confirmationData, // Add imported confirmation data
695
- additionalNotes: notes.additionalNotes, // Optional - pass as-is
688
+ includeConfirmation: notes.includeConfirmation ?? false,
696
689
  boxAnnotations: notes.boxAnnotations || [],
697
- earliestAnnotationTimestamp: notes.earliestAnnotationTimestamp,
698
690
  updatedAt: notes.updatedAt || new Date().toISOString()
699
691
  });
700
692
  } else {
@@ -874,6 +866,7 @@ export const Striae = ({ user }: StriaePage) => {
874
866
  setShowNotes={setShowNotes}
875
867
  onAnnotationRefresh={refreshAnnotationData}
876
868
  isReadOnly={isReadOnlyCase}
869
+ isReviewOnlyCase={isReviewOnlyCase}
877
870
  isArchivedCase={archiveDetails.archived}
878
871
  isConfirmed={!!annotationData?.confirmationData}
879
872
  confirmationSaveVersion={confirmationSaveVersion}
@@ -954,6 +947,7 @@ export const Striae = ({ user }: StriaePage) => {
954
947
  onAnnotationRefresh={refreshAnnotationData}
955
948
  originalFileName={files.find(file => file.id === imageId)?.originalFilename}
956
949
  isUploading={isUploading}
950
+ isReadOnly={isReadOnlyCase}
957
951
  showNotification={showNotification}
958
952
  />
959
953
  <UserAuditViewer
@@ -1,5 +1,7 @@
1
1
  // Annotation-related types and interfaces
2
2
 
3
+ export type ItemType = 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
4
+
3
5
  export interface BoxAnnotation {
4
6
  id: string;
5
7
  x: number;
@@ -72,19 +74,40 @@ export interface AnnotationData {
72
74
  leftItem: string;
73
75
  rightItem: string;
74
76
  caseFontColor?: string;
75
- classType?: 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
77
+ // Left item class characteristics
78
+ leftItemType?: ItemType;
79
+ leftCustomClass?: string;
80
+ leftClassNote?: string;
81
+ leftBulletData?: BulletAnnotationData;
82
+ leftCartridgeCaseData?: CartridgeCaseAnnotationData;
83
+ leftShotshellData?: ShotshellAnnotationData;
84
+ leftHasSubclass?: boolean;
85
+ // Right item class characteristics
86
+ rightItemType?: ItemType;
87
+ rightCustomClass?: string;
88
+ rightClassNote?: string;
89
+ rightBulletData?: BulletAnnotationData;
90
+ rightCartridgeCaseData?: CartridgeCaseAnnotationData;
91
+ rightShotshellData?: ShotshellAnnotationData;
92
+ rightHasSubclass?: boolean;
93
+ // Deprecated (kept for backward compatibility): use leftItemType, rightItemType, etc.
94
+ itemType?: ItemType;
95
+ /** @deprecated Pre-split legacy field; use leftItemType / rightItemType instead. */
96
+ classType?: string;
76
97
  customClass?: string;
77
98
  classNote?: string;
78
- indexType?: 'number' | 'color';
79
- indexNumber?: string;
80
- indexColor?: string;
81
- supportLevel?: 'ID' | 'Exclusion' | 'Inconclusive';
82
99
  bulletData?: BulletAnnotationData;
83
100
  cartridgeCaseData?: CartridgeCaseAnnotationData;
84
101
  shotshellData?: ShotshellAnnotationData;
85
102
  hasSubclass?: boolean;
103
+ indexType?: 'number' | 'color';
104
+ indexNumber?: string;
105
+ indexColor?: string;
106
+ supportLevel?: 'ID' | 'Exclusion' | 'Inconclusive';
86
107
  includeConfirmation: boolean;
87
108
  confirmationData?: ConfirmationData;
109
+ leftAdditionalNotes?: string;
110
+ rightAdditionalNotes?: string;
88
111
  additionalNotes?: string;
89
112
  boxAnnotations?: BoxAnnotation[];
90
113
  updatedAt: string;
@@ -1,11 +1,13 @@
1
1
  import type { User } from 'firebase/auth';
2
- import { type AnnotationData } from '~/types';
2
+ import { type AnnotationData, type ItemType } from '~/types';
3
3
 
4
4
  export interface FileConfirmationSummary {
5
5
  includeConfirmation: boolean;
6
6
  isConfirmed: boolean;
7
7
  updatedAt: string;
8
- classType?: 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
8
+ itemType?: ItemType;
9
+ leftItemType?: ItemType;
10
+ rightItemType?: ItemType;
9
11
  }
10
12
 
11
13
  export interface CaseConfirmationSummary {
@@ -195,8 +197,17 @@ function normalizeFileConfirmationSummary(value: unknown): FileConfirmationSumma
195
197
  };
196
198
  }
197
199
 
198
- const classType = value.classType;
199
- const normalizedClassType = typeof classType === 'string' && ['Bullet', 'Cartridge Case', 'Shotshell', 'Other'].includes(classType) ? (classType as 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other') : undefined;
200
+ // Support both new 'itemType' and legacy 'classType' properties
201
+ const itemType = value.itemType ?? value.classType;
202
+ const normalizedItemType = typeof itemType === 'string' && ['Bullet', 'Cartridge Case', 'Shotshell', 'Other'].includes(itemType) ? (itemType as ItemType) : undefined;
203
+ const normalizedLeftItemType =
204
+ typeof value.leftItemType === 'string' && ['Bullet', 'Cartridge Case', 'Shotshell', 'Other'].includes(value.leftItemType)
205
+ ? (value.leftItemType as ItemType)
206
+ : undefined;
207
+ const normalizedRightItemType =
208
+ typeof value.rightItemType === 'string' && ['Bullet', 'Cartridge Case', 'Shotshell', 'Other'].includes(value.rightItemType)
209
+ ? (value.rightItemType as ItemType)
210
+ : undefined;
200
211
 
201
212
  const summary: FileConfirmationSummary = {
202
213
  includeConfirmation: value.includeConfirmation === true,
@@ -204,8 +215,16 @@ function normalizeFileConfirmationSummary(value: unknown): FileConfirmationSumma
204
215
  updatedAt: typeof value.updatedAt === 'string' && value.updatedAt.length > 0 ? value.updatedAt : getIsoNow()
205
216
  };
206
217
 
207
- if (normalizedClassType) {
208
- summary.classType = normalizedClassType;
218
+ if (normalizedItemType) {
219
+ summary.itemType = normalizedItemType;
220
+ }
221
+
222
+ if (normalizedLeftItemType) {
223
+ summary.leftItemType = normalizedLeftItemType;
224
+ }
225
+
226
+ if (normalizedRightItemType) {
227
+ summary.rightItemType = normalizedRightItemType;
209
228
  }
210
229
 
211
230
  return summary;
@@ -237,6 +256,8 @@ export function computeCaseConfirmationAggregate(filesById: Record<string, FileC
237
256
 
238
257
  export function toFileConfirmationSummary(annotationData: AnnotationData | null): FileConfirmationSummary {
239
258
  const includeConfirmation = annotationData?.includeConfirmation === true;
259
+ const leftItemType = annotationData?.leftItemType || annotationData?.itemType || (annotationData?.classType as ItemType | undefined);
260
+ const rightItemType = annotationData?.rightItemType;
240
261
 
241
262
  const summary: FileConfirmationSummary = {
242
263
  includeConfirmation,
@@ -244,8 +265,19 @@ export function toFileConfirmationSummary(annotationData: AnnotationData | null)
244
265
  updatedAt: getIsoNow()
245
266
  };
246
267
 
247
- if (annotationData?.classType) {
248
- summary.classType = annotationData.classType;
268
+ if (leftItemType) {
269
+ summary.leftItemType = leftItemType;
270
+ }
271
+
272
+ if (rightItemType) {
273
+ summary.rightItemType = rightItemType;
274
+ }
275
+
276
+ // Keep legacy single-value item type for existing consumers.
277
+ if (leftItemType) {
278
+ summary.itemType = leftItemType;
279
+ } else if (rightItemType) {
280
+ summary.itemType = rightItemType;
249
281
  }
250
282
 
251
283
  return summary;
@@ -1,7 +1,7 @@
1
1
  import type { FileData } from '~/types';
2
2
  import type { FileConfirmationSummary } from '~/utils/data';
3
3
 
4
- export type FilesModalSortBy = 'recent' | 'filename' | 'confirmation' | 'classType';
4
+ export type FilesModalSortBy = 'recent' | 'filename' | 'confirmation' | 'itemType';
5
5
 
6
6
  export type FilesModalConfirmationFilter =
7
7
  | 'all'
@@ -9,17 +9,22 @@ export type FilesModalConfirmationFilter =
9
9
  | 'confirmed'
10
10
  | 'none-requested';
11
11
 
12
- export type FilesModalClassTypeFilter =
12
+ export type FilesModalItemTypeFilter =
13
13
  | 'all'
14
14
  | 'Bullet'
15
15
  | 'Cartridge Case'
16
16
  | 'Shotshell'
17
17
  | 'Other';
18
18
 
19
+ // Backwards compatibility alias
20
+ export type FilesModalClassTypeFilter = FilesModalItemTypeFilter;
21
+
19
22
  export interface FilesModalPreferences {
20
23
  sortBy: FilesModalSortBy;
21
24
  confirmationFilter: FilesModalConfirmationFilter;
22
- classTypeFilter: FilesModalClassTypeFilter;
25
+ itemTypeFilter: FilesModalItemTypeFilter;
26
+ // Backwards compatibility: legacy classTypeFilter will be migrated to itemTypeFilter
27
+ classTypeFilter?: FilesModalItemTypeFilter;
23
28
  }
24
29
 
25
30
  export type FileConfirmationById = Record<string, FileConfirmationSummary>;
@@ -34,6 +39,21 @@ function getFileConfirmationState(fileId: string, statusById: FileConfirmationBy
34
39
  return statusById[fileId] || DEFAULT_CONFIRMATION_SUMMARY;
35
40
  }
36
41
 
42
+ function getSummaryItemTypes(summary: FileConfirmationSummary): Array<NonNullable<FileConfirmationSummary['itemType']>> {
43
+ const types = [
44
+ summary.leftItemType,
45
+ summary.rightItemType,
46
+ summary.itemType,
47
+ ].filter((value): value is NonNullable<FileConfirmationSummary['itemType']> => Boolean(value));
48
+
49
+ return Array.from(new Set(types));
50
+ }
51
+
52
+ function getPrimaryItemType(summary: FileConfirmationSummary): FileConfirmationSummary['itemType'] {
53
+ const [first] = getSummaryItemTypes(summary);
54
+ return first;
55
+ }
56
+
37
57
  function getConfirmationRank(summary: FileConfirmationSummary): number {
38
58
  if (summary.includeConfirmation && !summary.isConfirmed) {
39
59
  return 0;
@@ -46,20 +66,20 @@ function getConfirmationRank(summary: FileConfirmationSummary): number {
46
66
  return 2;
47
67
  }
48
68
 
49
- function getClassTypeRank(classType: FileConfirmationSummary['classType']): number {
50
- if (classType === 'Bullet') {
69
+ function getItemTypeRank(itemType: FileConfirmationSummary['itemType']): number {
70
+ if (itemType === 'Bullet') {
51
71
  return 0;
52
72
  }
53
73
 
54
- if (classType === 'Cartridge Case') {
74
+ if (itemType === 'Cartridge Case') {
55
75
  return 1;
56
76
  }
57
77
 
58
- if (classType === 'Shotshell') {
78
+ if (itemType === 'Shotshell') {
59
79
  return 2;
60
80
  }
61
81
 
62
- if (classType === 'Other') {
82
+ if (itemType === 'Other') {
63
83
  return 3;
64
84
  }
65
85
 
@@ -90,20 +110,22 @@ function matchesConfirmationFilter(
90
110
  return !summary.includeConfirmation;
91
111
  }
92
112
 
93
- function matchesClassTypeFilter(
113
+ function matchesItemTypeFilter(
94
114
  summary: FileConfirmationSummary,
95
- classTypeFilter: FilesModalClassTypeFilter
115
+ itemTypeFilter: FilesModalItemTypeFilter
96
116
  ): boolean {
97
- if (classTypeFilter === 'all') {
117
+ if (itemTypeFilter === 'all') {
98
118
  return true;
99
119
  }
100
120
 
101
- if (classTypeFilter === 'Other') {
102
- // Treat legacy/unset class types as Other for filtering.
103
- return summary.classType === 'Other' || !summary.classType;
121
+ const itemTypes = getSummaryItemTypes(summary);
122
+
123
+ if (itemTypeFilter === 'Other') {
124
+ // Treat legacy/unset item types as Other for filtering.
125
+ return itemTypes.length === 0 || itemTypes.includes('Other');
104
126
  }
105
127
 
106
- return summary.classType === classTypeFilter;
128
+ return itemTypes.includes(itemTypeFilter);
107
129
  }
108
130
 
109
131
  function matchesSearch(file: FileData, query: string): boolean {
@@ -127,7 +149,7 @@ export function filterFilesForModal(
127
149
  return (
128
150
  matchesSearch(file, searchQuery) &&
129
151
  matchesConfirmationFilter(summary, preferences.confirmationFilter) &&
130
- matchesClassTypeFilter(summary, preferences.classTypeFilter)
152
+ matchesItemTypeFilter(summary, preferences.itemTypeFilter)
131
153
  );
132
154
  });
133
155
  }
@@ -177,7 +199,7 @@ export function sortFilesForModal(
177
199
  return next.sort((left, right) => {
178
200
  const leftSummary = getFileConfirmationState(left.id, statusById);
179
201
  const rightSummary = getFileConfirmationState(right.id, statusById);
180
- const difference = getClassTypeRank(leftSummary.classType) - getClassTypeRank(rightSummary.classType);
202
+ const difference = getItemTypeRank(getPrimaryItemType(leftSummary)) - getItemTypeRank(getPrimaryItemType(rightSummary));
181
203
 
182
204
  if (difference !== 0) {
183
205
  return difference;