@striae-org/striae 4.2.1 → 4.3.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 (66) hide show
  1. package/app/components/actions/case-import/confirmation-import.ts +20 -1
  2. package/app/components/actions/case-import/orchestrator.ts +3 -0
  3. package/app/components/actions/case-manage.ts +5 -1
  4. package/app/components/actions/confirm-export.ts +12 -3
  5. package/app/components/audit/viewer/audit-entries-list.tsx +20 -2
  6. package/app/components/audit/viewer/use-audit-viewer-export.ts +2 -2
  7. package/app/components/audit/viewer/use-audit-viewer-filters.ts +11 -1
  8. package/app/components/canvas/canvas.tsx +2 -1
  9. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  10. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  11. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  12. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  13. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  14. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  15. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  16. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  17. package/app/components/navbar/navbar.module.css +11 -0
  18. package/app/components/navbar/navbar.tsx +38 -19
  19. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -0
  20. package/app/components/sidebar/cases/case-sidebar.tsx +27 -3
  21. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  22. package/app/components/sidebar/cases/cases-modal.tsx +690 -110
  23. package/app/components/sidebar/cases/cases.module.css +23 -0
  24. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  25. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  26. package/app/components/sidebar/files/files-modal.module.css +285 -44
  27. package/app/components/sidebar/files/files-modal.tsx +452 -145
  28. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  29. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  30. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  31. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  32. package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
  33. package/app/components/sidebar/notes/notes.module.css +236 -4
  34. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  35. package/app/components/sidebar/sidebar-container.tsx +2 -0
  36. package/app/components/sidebar/sidebar.tsx +8 -1
  37. package/app/hooks/useCaseListPreferences.ts +99 -0
  38. package/app/hooks/useFileListPreferences.ts +106 -0
  39. package/app/routes/striae/striae.tsx +45 -1
  40. package/app/services/audit/audit-export-csv.ts +4 -2
  41. package/app/services/audit/audit-export-report.ts +36 -4
  42. package/app/services/audit/audit.service.ts +2 -0
  43. package/app/services/audit/builders/audit-entry-builder.ts +1 -0
  44. package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -2
  45. package/app/types/annotations.ts +48 -1
  46. package/app/types/audit.ts +1 -0
  47. package/app/utils/data/case-filters.ts +127 -0
  48. package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
  49. package/app/utils/data/file-filters.ts +201 -0
  50. package/app/utils/forensics/confirmation-signature.ts +20 -5
  51. package/functions/api/image/[[path]].ts +4 -0
  52. package/package.json +3 -4
  53. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  54. package/workers/data-worker/src/signing-payload-utils.ts +5 -0
  55. package/workers/data-worker/wrangler.jsonc.example +1 -1
  56. package/workers/image-worker/wrangler.jsonc.example +1 -1
  57. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  58. package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
  59. package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
  60. package/workers/pdf-worker/src/report-layout.ts +227 -0
  61. package/workers/pdf-worker/src/report-types.ts +20 -0
  62. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  63. package/workers/user-worker/wrangler.jsonc.example +1 -1
  64. package/wrangler.toml.example +1 -1
  65. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  66. /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
@@ -21,7 +21,7 @@ import { resolveEarliestAnnotationTimestamp } from '~/utils/ui';
21
21
  import { type AnnotationData, type FileData } from '~/types';
22
22
  import type * as CaseExportActions from '~/components/actions/case-export';
23
23
  import { checkCaseIsReadOnly, validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
24
- import { checkReadOnlyCaseExists } from '~/components/actions/case-review';
24
+ import { checkReadOnlyCaseExists, deleteReadOnlyCase } from '~/components/actions/case-review';
25
25
  import { canCreateCase, getLimitsDescription, getUserData } from '~/utils/data';
26
26
  import styles from './striae.module.css';
27
27
 
@@ -413,6 +413,39 @@ export const Striae = ({ user }: StriaePage) => {
413
413
  }
414
414
  };
415
415
 
416
+ const handleClearROCase = async () => {
417
+ if (!currentCase) {
418
+ showNotification('No read-only case is currently loaded.', 'error');
419
+ return;
420
+ }
421
+
422
+ const caseToRemove = currentCase;
423
+ const confirmed = window.confirm(
424
+ `Clear the read-only case "${caseToRemove}" from the workspace? This will remove the imported review data. The original exported case is not affected.`
425
+ );
426
+
427
+ if (!confirmed) {
428
+ return;
429
+ }
430
+
431
+ try {
432
+ const success = await deleteReadOnlyCase(user, caseToRemove);
433
+ if (!success) {
434
+ showNotification(`Failed to fully clear read-only case "${caseToRemove}". Please try again.`, 'error');
435
+ return;
436
+ }
437
+ setCurrentCase('');
438
+ setFiles([]);
439
+ handleImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
440
+ setShowNotes(false);
441
+ setIsAuditTrailOpen(false);
442
+ setIsRenameCaseModalOpen(false);
443
+ showNotification(`Read-only case "${caseToRemove}" cleared.`, 'success');
444
+ } catch (clearError) {
445
+ showNotification(clearError instanceof Error ? clearError.message : 'Failed to clear read-only case.', 'error');
446
+ }
447
+ };
448
+
416
449
  const handleArchiveCaseSubmit = async (archiveReason: string) => {
417
450
  if (!currentCase) {
418
451
  showNotification('Select a case before archiving.', 'error');
@@ -514,6 +547,7 @@ export const Striae = ({ user }: StriaePage) => {
514
547
  // Function to refresh annotation data (called when notes are saved)
515
548
  const refreshAnnotationData = () => {
516
549
  setAnnotationRefreshTrigger(prev => prev + 1);
550
+ setConfirmationSaveVersion(prev => prev + 1);
517
551
  };
518
552
 
519
553
  // Handle import/clear read-only case
@@ -522,6 +556,11 @@ export const Striae = ({ user }: StriaePage) => {
522
556
  if (result.caseNumber && result.isReadOnly) {
523
557
  // Successful read-only case import - load the case
524
558
  handleCaseChange(result.caseNumber);
559
+ } else if (result.caseNumber) {
560
+ setConfirmationSaveVersion(prev => prev + 1);
561
+ if (result.caseNumber === currentCase) {
562
+ refreshAnnotationData();
563
+ }
525
564
  } else if (!result.caseNumber && !result.isReadOnly) {
526
565
  // Read-only case cleared - reset all UI state
527
566
  setCurrentCase('');
@@ -722,6 +761,9 @@ export const Striae = ({ user }: StriaePage) => {
722
761
  void handleDeleteCaseAction();
723
762
  }}
724
763
  onArchiveCase={() => setIsArchiveCaseModalOpen(true)}
764
+ onClearROCase={() => {
765
+ void handleClearROCase();
766
+ }}
725
767
  onOpenViewAllFiles={() => setIsFilesModalOpen(true)}
726
768
  onDeleteCurrentFile={() => {
727
769
  void handleDeleteCurrentFileAction();
@@ -735,6 +777,7 @@ export const Striae = ({ user }: StriaePage) => {
735
777
  onOpenCase={() => {
736
778
  void handleOpenCaseModal();
737
779
  }}
780
+ onOpenCaseExport={() => setIsCaseExportModalOpen(true)}
738
781
  imageId={imageId}
739
782
  currentCase={currentCase}
740
783
  imageLoaded={imageLoaded}
@@ -745,6 +788,7 @@ export const Striae = ({ user }: StriaePage) => {
745
788
  setShowNotes={setShowNotes}
746
789
  onAnnotationRefresh={refreshAnnotationData}
747
790
  isReadOnly={isReadOnlyCase}
791
+ isArchivedCase={archiveDetails.archived}
748
792
  isConfirmed={!!annotationData?.confirmationData}
749
793
  confirmationSaveVersion={confirmationSaveVersion}
750
794
  isUploading={isUploading}
@@ -38,7 +38,8 @@ export const AUDIT_CSV_ENTRY_HEADERS = [
38
38
  'Total Files',
39
39
  'MFA Method',
40
40
  'Security Incident Type',
41
- 'Security Severity'
41
+ 'Security Severity',
42
+ 'Confirmed Files'
42
43
  ];
43
44
 
44
45
  export const formatForCSV = (value?: string | number | null): string => {
@@ -121,7 +122,8 @@ export const entryToCSVRow = (entry: ValidationAuditEntry): string => {
121
122
  caseDetails?.totalFiles?.toString() || '',
122
123
  formatForCSV(securityDetails?.mfaMethod),
123
124
  formatForCSV(securityDetails?.incidentType),
124
- formatForCSV(securityDetails?.severity)
125
+ formatForCSV(securityDetails?.severity),
126
+ formatForCSV(caseDetails?.confirmedFileNames?.join('; '))
125
127
  ];
126
128
 
127
129
  return values.join(',');
@@ -84,11 +84,13 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
84
84
 
85
85
  let totalConfirmationsImported = 0;
86
86
  let totalConfirmationsInFiles = 0;
87
- const reviewingExaminers = new Set<string>();
87
+ const reviewingExaminers = new Map<string, { uid: string; badgeId?: string; confirmedFiles: Set<string> }>();
88
+ const allConfirmedFiles = new Set<string>();
88
89
 
89
90
  imports.forEach(entry => {
90
91
  const metrics = entry.details.performanceMetrics;
91
92
  const caseDetails = entry.details.caseDetails;
93
+ const userProfileDetails = entry.details.userProfileDetails;
92
94
 
93
95
  if (metrics?.validationStepsCompleted) {
94
96
  totalConfirmationsImported += metrics.validationStepsCompleted;
@@ -97,10 +99,36 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
97
99
  totalConfirmationsInFiles += caseDetails.totalAnnotations;
98
100
  }
99
101
  if (entry.details.reviewingExaminerUid) {
100
- reviewingExaminers.add(entry.details.reviewingExaminerUid);
102
+ const uid = entry.details.reviewingExaminerUid;
103
+ const badgeId = userProfileDetails?.badgeId;
104
+ const confirmedFileNames = caseDetails?.confirmedFileNames || [];
105
+
106
+ if (!reviewingExaminers.has(uid)) {
107
+ reviewingExaminers.set(uid, {
108
+ uid,
109
+ badgeId,
110
+ confirmedFiles: new Set()
111
+ });
112
+ }
113
+
114
+ const examiner = reviewingExaminers.get(uid)!;
115
+ confirmedFileNames.forEach(file => {
116
+ examiner.confirmedFiles.add(file);
117
+ allConfirmedFiles.add(file);
118
+ });
101
119
  }
102
120
  });
103
121
 
122
+ const examinersDetail = Array.from(reviewingExaminers.values())
123
+ .map(examiner => {
124
+ const badgeInfo = examiner.badgeId ? ` (Badge: ${examiner.badgeId})` : '';
125
+ const filesInfo = examiner.confirmedFiles.size > 0
126
+ ? `\n Confirmed Files: ${Array.from(examiner.confirmedFiles).sort().join(', ')}`
127
+ : '';
128
+ return `- UID: ${examiner.uid}${badgeInfo}${filesInfo}`;
129
+ })
130
+ .join('\n');
131
+
104
132
  return [
105
133
  `Confirmation Operations: ${confirmationEntries.length}`,
106
134
  `- Imports: ${imports.length}`,
@@ -112,8 +140,12 @@ const generateConfirmationSummary = (entries: ValidationAuditEntry[]): string =>
112
140
  `Reviewing Examiners Involved: ${reviewingExaminers.size}`,
113
141
  '',
114
142
  reviewingExaminers.size > 0
115
- ? `External Reviewers: ${Array.from(reviewingExaminers).join(', ')}`
116
- : 'No external reviewers detected'
143
+ ? `External Reviewers:\n${examinersDetail}`
144
+ : 'No external reviewers detected',
145
+ '',
146
+ allConfirmedFiles.size > 0
147
+ ? `Successfully Confirmed Files (Total: ${allConfirmedFiles.size}):\n${Array.from(allConfirmedFiles).sort().map(file => ` - ${file}`).join('\n')}`
148
+ : 'No files confirmed'
117
149
  ].join('\n');
118
150
  };
119
151
 
@@ -340,6 +340,7 @@ export class AuditService {
340
340
  result: AuditResult,
341
341
  hashValid: boolean,
342
342
  confirmationsImported: number,
343
+ confirmedFileNames: string[] = [],
343
344
  errors: string[] = [],
344
345
  reviewingExaminerUid?: string,
345
346
  performanceMetrics?: PerformanceMetrics,
@@ -360,6 +361,7 @@ export class AuditService {
360
361
  result,
361
362
  hashValid,
362
363
  confirmationsImported,
364
+ confirmedFileNames,
363
365
  errors,
364
366
  reviewingExaminerUid,
365
367
  reviewerBadgeId,
@@ -19,6 +19,7 @@ export const buildValidationAuditEntry = (
19
19
  confirmationId: params.confirmationId,
20
20
  originalExaminerUid: params.originalExaminerUid,
21
21
  reviewingExaminerUid: params.reviewingExaminerUid,
22
+ reviewerBadgeId: params.reviewerBadgeId,
22
23
  workflowPhase: params.workflowPhase,
23
24
  securityChecks: params.securityChecks,
24
25
  performanceMetrics: params.performanceMetrics,
@@ -218,6 +218,7 @@ interface BuildConfirmationImportAuditParamsInput {
218
218
  result: AuditResult;
219
219
  hashValid: boolean;
220
220
  confirmationsImported: number;
221
+ confirmedFileNames?: string[];
221
222
  errors?: string[];
222
223
  reviewingExaminerUid?: string;
223
224
  reviewerBadgeId?: string;
@@ -273,8 +274,13 @@ export const buildConfirmationImportAuditParams = (
273
274
  reviewerBadgeId: input.reviewerBadgeId,
274
275
  caseDetails: input.totalConfirmationsInFile !== undefined
275
276
  ? {
276
- totalAnnotations: input.totalConfirmationsInFile
277
+ totalAnnotations: input.totalConfirmationsInFile,
278
+ confirmedFileNames: input.confirmedFileNames
277
279
  }
278
- : undefined
280
+ : input.confirmedFileNames
281
+ ? {
282
+ confirmedFileNames: input.confirmedFileNames
283
+ }
284
+ : undefined
279
285
  };
280
286
  };
@@ -22,19 +22,66 @@ export interface ConfirmationData {
22
22
  confirmedAt: string; // ISO timestamp of confirmation
23
23
  }
24
24
 
25
+ export interface BulletAnnotationData {
26
+ caliber?: string;
27
+ mass?: string;
28
+ diameter?: string;
29
+ calcDiameter?: string;
30
+ lgNumber?: number;
31
+ lgDirection?: string;
32
+ barrelType?: string;
33
+ // Width arrays should align with lgNumber:
34
+ // L1..Ln stored in order at lWidths[0..n-1], G1..Gn at gWidths[0..n-1].
35
+ lWidths?: string[];
36
+ gWidths?: string[];
37
+ jacketMetal?: string;
38
+ coreMetal?: string;
39
+ bulletType?: string;
40
+ }
41
+
42
+ export interface CartridgeCaseAnnotationData {
43
+ caliber?: string;
44
+ brand?: string;
45
+ metal?: string;
46
+ primerType?: string;
47
+ fpiShape?: string;
48
+ apertureShape?: string;
49
+ hasFpDrag?: boolean;
50
+ hasExtractorMarks?: boolean;
51
+ hasEjectorMarks?: boolean;
52
+ hasChamberMarks?: boolean;
53
+ hasMagazineLipMarks?: boolean;
54
+ hasPrimerShear?: boolean;
55
+ hasEjectionPortMarks?: boolean;
56
+ }
57
+
58
+ export interface ShotshellAnnotationData {
59
+ gauge?: string;
60
+ shotSize?: string;
61
+ metal?: string;
62
+ brand?: string;
63
+ fpiShape?: string;
64
+ hasExtractorMarks?: boolean;
65
+ hasEjectorMarks?: boolean;
66
+ hasChamberMarks?: boolean;
67
+ }
68
+
25
69
  export interface AnnotationData {
26
70
  leftCase: string;
27
71
  rightCase: string;
28
72
  leftItem: string;
29
73
  rightItem: string;
30
74
  caseFontColor?: string;
31
- classType?: 'Bullet' | 'Cartridge Case' | 'Other';
75
+ classType?: 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
32
76
  customClass?: string;
33
77
  classNote?: string;
34
78
  indexType?: 'number' | 'color';
35
79
  indexNumber?: string;
36
80
  indexColor?: string;
37
81
  supportLevel?: 'ID' | 'Exclusion' | 'Inconclusive';
82
+ bulletData?: BulletAnnotationData;
83
+ cartridgeCaseData?: CartridgeCaseAnnotationData;
84
+ shotshellData?: ShotshellAnnotationData;
38
85
  hasSubclass?: boolean;
39
86
  includeConfirmation: boolean;
40
87
  confirmationData?: ConfirmationData;
@@ -199,6 +199,7 @@ export interface CaseAuditDetails {
199
199
  newCaseName?: string;
200
200
  totalFiles?: number;
201
201
  totalAnnotations?: number;
202
+ confirmedFileNames?: string[];
202
203
  createdDate?: string;
203
204
  lastModified?: string;
204
205
  deleteReason?: string;
@@ -0,0 +1,127 @@
1
+ export type CasesModalSortBy = 'recent' | 'alphabetical';
2
+
3
+ export type CasesModalConfirmationFilter =
4
+ | 'all'
5
+ | 'pending'
6
+ | 'confirmed'
7
+ | 'none-requested';
8
+
9
+ export interface CasesModalPreferences {
10
+ sortBy: CasesModalSortBy;
11
+ confirmationFilter: CasesModalConfirmationFilter;
12
+ showArchivedOnly: boolean;
13
+ }
14
+
15
+ export interface CasesModalCaseItem {
16
+ caseNumber: string;
17
+ createdAt: string;
18
+ archived: boolean;
19
+ isReadOnly: boolean;
20
+ }
21
+
22
+ export interface CaseConfirmationStatusValue {
23
+ includeConfirmation: boolean;
24
+ isConfirmed: boolean;
25
+ }
26
+
27
+ const DEFAULT_CASE_CONFIRMATION_STATUS: CaseConfirmationStatusValue = {
28
+ includeConfirmation: false,
29
+ isConfirmed: false,
30
+ };
31
+
32
+ function compareCaseNumbersAlphabetically(a: string, b: string): number {
33
+ const getComponents = (value: string) => {
34
+ const numbers = value.match(/\d+/g)?.map(Number) || [];
35
+ const letters = value.match(/[A-Za-z]+/g)?.join('') || '';
36
+ return { numbers, letters };
37
+ };
38
+
39
+ const left = getComponents(a);
40
+ const right = getComponents(b);
41
+
42
+ const maxLength = Math.max(left.numbers.length, right.numbers.length);
43
+ for (let index = 0; index < maxLength; index += 1) {
44
+ const leftNumber = left.numbers[index] || 0;
45
+ const rightNumber = right.numbers[index] || 0;
46
+
47
+ if (leftNumber !== rightNumber) {
48
+ return leftNumber - rightNumber;
49
+ }
50
+ }
51
+
52
+ return left.letters.localeCompare(right.letters);
53
+ }
54
+
55
+ function parseTimestamp(value: string): number {
56
+ const parsed = Date.parse(value);
57
+ return Number.isNaN(parsed) ? 0 : parsed;
58
+ }
59
+
60
+ function matchesConfirmationFilter(
61
+ caseNumber: string,
62
+ confirmationFilter: CasesModalConfirmationFilter,
63
+ caseConfirmationStatus: Record<string, CaseConfirmationStatusValue>
64
+ ): boolean {
65
+ if (confirmationFilter === 'all') {
66
+ return true;
67
+ }
68
+
69
+ const status = caseConfirmationStatus[caseNumber] || DEFAULT_CASE_CONFIRMATION_STATUS;
70
+
71
+ if (confirmationFilter === 'pending') {
72
+ return status.includeConfirmation && !status.isConfirmed;
73
+ }
74
+
75
+ if (confirmationFilter === 'confirmed') {
76
+ return status.includeConfirmation && status.isConfirmed;
77
+ }
78
+
79
+ return !status.includeConfirmation;
80
+ }
81
+
82
+ export function filterCasesForModal(
83
+ cases: CasesModalCaseItem[],
84
+ preferences: CasesModalPreferences,
85
+ caseConfirmationStatus: Record<string, CaseConfirmationStatusValue>
86
+ ): CasesModalCaseItem[] {
87
+ const archiveFilteredCases = preferences.showArchivedOnly
88
+ ? cases.filter((entry) => entry.archived && !entry.isReadOnly)
89
+ : cases.filter((entry) => !entry.archived && !entry.isReadOnly);
90
+
91
+ return archiveFilteredCases.filter((entry) =>
92
+ matchesConfirmationFilter(entry.caseNumber, preferences.confirmationFilter, caseConfirmationStatus)
93
+ );
94
+ }
95
+
96
+ export function sortCasesForModal(
97
+ cases: CasesModalCaseItem[],
98
+ sortBy: CasesModalSortBy
99
+ ): CasesModalCaseItem[] {
100
+ const next = [...cases];
101
+
102
+ if (sortBy === 'recent') {
103
+ return next.sort((left, right) => {
104
+ const difference = parseTimestamp(right.createdAt) - parseTimestamp(left.createdAt);
105
+ if (difference !== 0) {
106
+ return difference;
107
+ }
108
+
109
+ return compareCaseNumbersAlphabetically(left.caseNumber, right.caseNumber);
110
+ });
111
+ }
112
+
113
+ return next.sort((left, right) =>
114
+ compareCaseNumbersAlphabetically(left.caseNumber, right.caseNumber)
115
+ );
116
+ }
117
+
118
+ export function getCasesForModal(
119
+ cases: CasesModalCaseItem[],
120
+ preferences: CasesModalPreferences,
121
+ caseConfirmationStatus: Record<string, CaseConfirmationStatusValue>
122
+ ): CasesModalCaseItem[] {
123
+ return sortCasesForModal(
124
+ filterCasesForModal(cases, preferences, caseConfirmationStatus),
125
+ preferences.sortBy
126
+ );
127
+ }
@@ -5,6 +5,7 @@ export interface FileConfirmationSummary {
5
5
  includeConfirmation: boolean;
6
6
  isConfirmed: boolean;
7
7
  updatedAt: string;
8
+ classType?: 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
8
9
  }
9
10
 
10
11
  export interface CaseConfirmationSummary {
@@ -194,11 +195,20 @@ function normalizeFileConfirmationSummary(value: unknown): FileConfirmationSumma
194
195
  };
195
196
  }
196
197
 
197
- return {
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
+
201
+ const summary: FileConfirmationSummary = {
198
202
  includeConfirmation: value.includeConfirmation === true,
199
203
  isConfirmed: value.isConfirmed === true,
200
204
  updatedAt: typeof value.updatedAt === 'string' && value.updatedAt.length > 0 ? value.updatedAt : getIsoNow()
201
205
  };
206
+
207
+ if (normalizedClassType) {
208
+ summary.classType = normalizedClassType;
209
+ }
210
+
211
+ return summary;
202
212
  }
203
213
 
204
214
  export function isStaleTimestamp(timestamp: string, maxAgeMs: number): boolean {
@@ -228,11 +238,17 @@ export function computeCaseConfirmationAggregate(filesById: Record<string, FileC
228
238
  export function toFileConfirmationSummary(annotationData: AnnotationData | null): FileConfirmationSummary {
229
239
  const includeConfirmation = annotationData?.includeConfirmation === true;
230
240
 
231
- return {
241
+ const summary: FileConfirmationSummary = {
232
242
  includeConfirmation,
233
243
  isConfirmed: includeConfirmation && !!annotationData?.confirmationData,
234
244
  updatedAt: getIsoNow()
235
245
  };
246
+
247
+ if (annotationData?.classType) {
248
+ summary.classType = annotationData.classType;
249
+ }
250
+
251
+ return summary;
236
252
  }
237
253
 
238
254
  export function normalizeConfirmationSummaryDocument(payload: unknown): UserConfirmationSummaryDocument {
@@ -0,0 +1,201 @@
1
+ import type { FileData } from '~/types';
2
+ import type { FileConfirmationSummary } from '~/utils/data';
3
+
4
+ export type FilesModalSortBy = 'recent' | 'filename' | 'confirmation' | 'classType';
5
+
6
+ export type FilesModalConfirmationFilter =
7
+ | 'all'
8
+ | 'pending'
9
+ | 'confirmed'
10
+ | 'none-requested';
11
+
12
+ export type FilesModalClassTypeFilter =
13
+ | 'all'
14
+ | 'Bullet'
15
+ | 'Cartridge Case'
16
+ | 'Shotshell'
17
+ | 'Other';
18
+
19
+ export interface FilesModalPreferences {
20
+ sortBy: FilesModalSortBy;
21
+ confirmationFilter: FilesModalConfirmationFilter;
22
+ classTypeFilter: FilesModalClassTypeFilter;
23
+ }
24
+
25
+ export type FileConfirmationById = Record<string, FileConfirmationSummary>;
26
+
27
+ const DEFAULT_CONFIRMATION_SUMMARY: FileConfirmationSummary = {
28
+ includeConfirmation: false,
29
+ isConfirmed: false,
30
+ updatedAt: '',
31
+ };
32
+
33
+ function getFileConfirmationState(fileId: string, statusById: FileConfirmationById): FileConfirmationSummary {
34
+ return statusById[fileId] || DEFAULT_CONFIRMATION_SUMMARY;
35
+ }
36
+
37
+ function getConfirmationRank(summary: FileConfirmationSummary): number {
38
+ if (summary.includeConfirmation && !summary.isConfirmed) {
39
+ return 0;
40
+ }
41
+
42
+ if (summary.includeConfirmation && summary.isConfirmed) {
43
+ return 1;
44
+ }
45
+
46
+ return 2;
47
+ }
48
+
49
+ function getClassTypeRank(classType: FileConfirmationSummary['classType']): number {
50
+ if (classType === 'Bullet') {
51
+ return 0;
52
+ }
53
+
54
+ if (classType === 'Cartridge Case') {
55
+ return 1;
56
+ }
57
+
58
+ if (classType === 'Shotshell') {
59
+ return 2;
60
+ }
61
+
62
+ if (classType === 'Other') {
63
+ return 3;
64
+ }
65
+
66
+ return 4;
67
+ }
68
+
69
+ function parseTimestamp(value: string): number {
70
+ const parsed = Date.parse(value);
71
+ return Number.isNaN(parsed) ? 0 : parsed;
72
+ }
73
+
74
+ function matchesConfirmationFilter(
75
+ summary: FileConfirmationSummary,
76
+ confirmationFilter: FilesModalConfirmationFilter
77
+ ): boolean {
78
+ if (confirmationFilter === 'all') {
79
+ return true;
80
+ }
81
+
82
+ if (confirmationFilter === 'pending') {
83
+ return summary.includeConfirmation && !summary.isConfirmed;
84
+ }
85
+
86
+ if (confirmationFilter === 'confirmed') {
87
+ return summary.includeConfirmation && summary.isConfirmed;
88
+ }
89
+
90
+ return !summary.includeConfirmation;
91
+ }
92
+
93
+ function matchesClassTypeFilter(
94
+ summary: FileConfirmationSummary,
95
+ classTypeFilter: FilesModalClassTypeFilter
96
+ ): boolean {
97
+ if (classTypeFilter === 'all') {
98
+ return true;
99
+ }
100
+
101
+ if (classTypeFilter === 'Other') {
102
+ // Treat legacy/unset class types as Other for filtering.
103
+ return summary.classType === 'Other' || !summary.classType;
104
+ }
105
+
106
+ return summary.classType === classTypeFilter;
107
+ }
108
+
109
+ function matchesSearch(file: FileData, query: string): boolean {
110
+ const normalized = query.trim().toLowerCase();
111
+ if (!normalized) {
112
+ return true;
113
+ }
114
+
115
+ return file.originalFilename.toLowerCase().includes(normalized);
116
+ }
117
+
118
+ export function filterFilesForModal(
119
+ files: FileData[],
120
+ preferences: FilesModalPreferences,
121
+ statusById: FileConfirmationById,
122
+ searchQuery: string
123
+ ): FileData[] {
124
+ return files.filter((file) => {
125
+ const summary = getFileConfirmationState(file.id, statusById);
126
+
127
+ return (
128
+ matchesSearch(file, searchQuery) &&
129
+ matchesConfirmationFilter(summary, preferences.confirmationFilter) &&
130
+ matchesClassTypeFilter(summary, preferences.classTypeFilter)
131
+ );
132
+ });
133
+ }
134
+
135
+ function compareFileNames(a: string, b: string): number {
136
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
137
+ }
138
+
139
+ export function sortFilesForModal(
140
+ files: FileData[],
141
+ sortBy: FilesModalSortBy,
142
+ statusById: FileConfirmationById
143
+ ): FileData[] {
144
+ const next = [...files];
145
+
146
+ if (sortBy === 'recent') {
147
+ return next.sort((left, right) => {
148
+ const difference = parseTimestamp(right.uploadedAt) - parseTimestamp(left.uploadedAt);
149
+ if (difference !== 0) {
150
+ return difference;
151
+ }
152
+
153
+ return compareFileNames(left.originalFilename, right.originalFilename);
154
+ });
155
+ }
156
+
157
+ if (sortBy === 'filename') {
158
+ return next.sort((left, right) =>
159
+ compareFileNames(left.originalFilename, right.originalFilename)
160
+ );
161
+ }
162
+
163
+ if (sortBy === 'confirmation') {
164
+ return next.sort((left, right) => {
165
+ const leftSummary = getFileConfirmationState(left.id, statusById);
166
+ const rightSummary = getFileConfirmationState(right.id, statusById);
167
+ const difference = getConfirmationRank(leftSummary) - getConfirmationRank(rightSummary);
168
+
169
+ if (difference !== 0) {
170
+ return difference;
171
+ }
172
+
173
+ return compareFileNames(left.originalFilename, right.originalFilename);
174
+ });
175
+ }
176
+
177
+ return next.sort((left, right) => {
178
+ const leftSummary = getFileConfirmationState(left.id, statusById);
179
+ const rightSummary = getFileConfirmationState(right.id, statusById);
180
+ const difference = getClassTypeRank(leftSummary.classType) - getClassTypeRank(rightSummary.classType);
181
+
182
+ if (difference !== 0) {
183
+ return difference;
184
+ }
185
+
186
+ return compareFileNames(left.originalFilename, right.originalFilename);
187
+ });
188
+ }
189
+
190
+ export function getFilesForModal(
191
+ files: FileData[],
192
+ preferences: FilesModalPreferences,
193
+ statusById: FileConfirmationById,
194
+ searchQuery: string
195
+ ): FileData[] {
196
+ return sortFilesForModal(
197
+ filterFilesForModal(files, preferences, statusById, searchQuery),
198
+ preferences.sortBy,
199
+ statusById
200
+ );
201
+ }