@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.
- package/app/components/actions/case-import/confirmation-import.ts +20 -1
- package/app/components/actions/case-import/orchestrator.ts +3 -0
- package/app/components/actions/case-manage.ts +5 -1
- package/app/components/actions/confirm-export.ts +12 -3
- package/app/components/audit/viewer/audit-entries-list.tsx +20 -2
- package/app/components/audit/viewer/use-audit-viewer-export.ts +2 -2
- package/app/components/audit/viewer/use-audit-viewer-filters.ts +11 -1
- package/app/components/canvas/canvas.tsx +2 -1
- package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
- package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
- package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
- package/app/components/navbar/navbar.module.css +11 -0
- package/app/components/navbar/navbar.tsx +38 -19
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +27 -3
- package/app/components/sidebar/cases/cases-modal.module.css +312 -10
- package/app/components/sidebar/cases/cases-modal.tsx +690 -110
- package/app/components/sidebar/cases/cases.module.css +23 -0
- package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
- package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
- package/app/components/sidebar/files/files-modal.module.css +285 -44
- package/app/components/sidebar/files/files-modal.tsx +452 -145
- package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
- package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
- package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
- package/app/components/sidebar/notes/class-details-shared.ts +239 -0
- package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
- package/app/components/sidebar/notes/notes.module.css +236 -4
- package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
- package/app/components/sidebar/sidebar-container.tsx +2 -0
- package/app/components/sidebar/sidebar.tsx +8 -1
- package/app/hooks/useCaseListPreferences.ts +99 -0
- package/app/hooks/useFileListPreferences.ts +106 -0
- package/app/routes/striae/striae.tsx +45 -1
- package/app/services/audit/audit-export-csv.ts +4 -2
- package/app/services/audit/audit-export-report.ts +36 -4
- package/app/services/audit/audit.service.ts +2 -0
- package/app/services/audit/builders/audit-entry-builder.ts +1 -0
- package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -2
- package/app/types/annotations.ts +48 -1
- package/app/types/audit.ts +1 -0
- package/app/utils/data/case-filters.ts +127 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
- package/app/utils/data/file-filters.ts +201 -0
- package/app/utils/forensics/confirmation-signature.ts +20 -5
- package/functions/api/image/[[path]].ts +4 -0
- package/package.json +3 -4
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/signing-payload-utils.ts +5 -0
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
- package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
- package/workers/pdf-worker/src/report-layout.ts +227 -0
- package/workers/pdf-worker/src/report-types.ts +20 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- /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
|
-
|
|
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
|
|
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
|
-
:
|
|
280
|
+
: input.confirmedFileNames
|
|
281
|
+
? {
|
|
282
|
+
confirmedFileNames: input.confirmedFileNames
|
|
283
|
+
}
|
|
284
|
+
: undefined
|
|
279
285
|
};
|
|
280
286
|
};
|
package/app/types/annotations.ts
CHANGED
|
@@ -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;
|
package/app/types/audit.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|