@striae-org/striae 4.2.0 → 4.3.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 (90) hide show
  1. package/LICENSE +1 -1
  2. package/app/components/actions/case-manage.ts +50 -17
  3. package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
  4. package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
  5. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  6. package/app/components/canvas/confirmation/confirmation.tsx +6 -2
  7. package/app/components/colors/colors.module.css +4 -3
  8. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  9. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  10. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  11. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  12. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  13. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  14. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  15. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  16. package/app/components/navbar/navbar.tsx +34 -9
  17. package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
  18. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  19. package/app/components/sidebar/cases/cases-modal.tsx +737 -116
  20. package/app/components/sidebar/cases/cases.module.css +43 -0
  21. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  22. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  23. package/app/components/sidebar/files/files-modal.module.css +285 -44
  24. package/app/components/sidebar/files/files-modal.tsx +482 -177
  25. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  26. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  27. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  28. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  29. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  30. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
  31. package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
  32. package/app/components/sidebar/notes/notes.module.css +262 -14
  33. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  34. package/app/components/sidebar/sidebar-container.tsx +2 -0
  35. package/app/components/sidebar/sidebar.tsx +15 -1
  36. package/app/{tailwind.css → global.css} +1 -3
  37. package/app/hooks/useCaseListPreferences.ts +99 -0
  38. package/app/hooks/useFileListPreferences.ts +106 -0
  39. package/app/hooks/useOverlayDismiss.ts +6 -4
  40. package/app/root.tsx +1 -1
  41. package/app/routes/striae/striae.tsx +7 -0
  42. package/app/services/audit/audit.service.ts +2 -2
  43. package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
  44. package/app/types/annotations.ts +48 -1
  45. package/app/types/audit.ts +1 -0
  46. package/app/utils/data/case-filters.ts +127 -0
  47. package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
  48. package/app/utils/data/data-operations.ts +17 -861
  49. package/app/utils/data/file-filters.ts +201 -0
  50. package/app/utils/data/index.ts +11 -1
  51. package/app/utils/data/operations/batch-operations.ts +113 -0
  52. package/app/utils/data/operations/case-operations.ts +168 -0
  53. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  54. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  55. package/app/utils/data/operations/index.ts +7 -0
  56. package/app/utils/data/operations/signing-operations.ts +225 -0
  57. package/app/utils/data/operations/types.ts +42 -0
  58. package/app/utils/data/operations/validation-operations.ts +48 -0
  59. package/app/utils/forensics/export-verification.ts +40 -111
  60. package/functions/api/_shared/firebase-auth.ts +2 -7
  61. package/functions/api/image/[[path]].ts +23 -22
  62. package/functions/api/pdf/[[path]].ts +27 -8
  63. package/package.json +7 -13
  64. package/scripts/deploy-primershear-emails.sh +1 -1
  65. package/worker-configuration.d.ts +2 -2
  66. package/workers/audit-worker/package.json +1 -1
  67. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  68. package/workers/data-worker/package.json +1 -1
  69. package/workers/data-worker/wrangler.jsonc.example +1 -1
  70. package/workers/image-worker/package.json +1 -1
  71. package/workers/image-worker/src/image-worker.example.ts +16 -5
  72. package/workers/image-worker/wrangler.jsonc.example +1 -1
  73. package/workers/keys-worker/package.json +1 -1
  74. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  75. package/workers/pdf-worker/package.json +1 -1
  76. package/workers/pdf-worker/src/formats/format-striae.ts +84 -124
  77. package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
  78. package/workers/pdf-worker/src/report-layout.ts +227 -0
  79. package/workers/pdf-worker/src/report-types.ts +23 -3
  80. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  81. package/workers/user-worker/package.json +1 -1
  82. package/workers/user-worker/src/user-worker.example.ts +17 -0
  83. package/workers/user-worker/wrangler.jsonc.example +1 -1
  84. package/wrangler.toml.example +1 -1
  85. package/NOTICE +0 -13
  86. package/app/components/sidebar/notes/notes-modal.tsx +0 -52
  87. package/postcss.config.js +0 -6
  88. package/tailwind.config.ts +0 -22
  89. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  90. /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
@@ -13,6 +13,7 @@ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
13
13
  interface SidebarContainerProps {
14
14
  user: User;
15
15
  onImageSelect: (file: FileData) => void;
16
+ onOpenCase: () => void;
16
17
  imageId?: string;
17
18
  currentCase: string;
18
19
  files: FileData[];
@@ -23,6 +24,7 @@ interface SidebarContainerProps {
23
24
  setShowNotes: (show: boolean) => void;
24
25
  onAnnotationRefresh?: () => void;
25
26
  isReadOnly?: boolean;
27
+ isArchivedCase?: boolean;
26
28
  isConfirmed?: boolean;
27
29
  confirmationSaveVersion?: number;
28
30
  isUploading?: boolean;
@@ -1,4 +1,5 @@
1
1
  import type { User } from 'firebase/auth';
2
+ import type React from 'react';
2
3
  import { useState, useCallback } from 'react';
3
4
  import styles from './sidebar.module.css';
4
5
  import { CaseSidebar } from './cases/case-sidebar';
@@ -8,6 +9,7 @@ import { type FileData } from '~/types';
8
9
  interface SidebarProps {
9
10
  user: User;
10
11
  onImageSelect: (file: FileData) => void;
12
+ onOpenCase: () => void;
11
13
  imageId?: string;
12
14
  currentCase: string;
13
15
  files: FileData[];
@@ -18,6 +20,7 @@ interface SidebarProps {
18
20
  setShowNotes: (show: boolean) => void;
19
21
  onAnnotationRefresh?: () => void;
20
22
  isReadOnly?: boolean;
23
+ isArchivedCase?: boolean;
21
24
  isConfirmed?: boolean;
22
25
  confirmationSaveVersion?: number;
23
26
  isUploading?: boolean;
@@ -27,6 +30,7 @@ interface SidebarProps {
27
30
  export const Sidebar = ({
28
31
  user,
29
32
  onImageSelect,
33
+ onOpenCase,
30
34
  imageId,
31
35
  currentCase,
32
36
  imageLoaded,
@@ -35,6 +39,7 @@ export const Sidebar = ({
35
39
  setFiles,
36
40
  setShowNotes,
37
41
  isReadOnly = false,
42
+ isArchivedCase = false,
38
43
  isConfirmed = false,
39
44
  confirmationSaveVersion = 0,
40
45
  isUploading: initialIsUploading = false,
@@ -67,13 +72,20 @@ export const Sidebar = ({
67
72
  setToastMessage(`${result.successCount} file${result.successCount !== 1 ? 's' : ''} uploaded!`);
68
73
  }
69
74
  setIsToastVisible(true);
70
- }, []);
75
+ }, []);
76
+
77
+ const handleExportNotification = useCallback((message: string, type: 'success' | 'error') => {
78
+ setToastType(type);
79
+ setToastMessage(message);
80
+ setIsToastVisible(true);
81
+ }, []);
71
82
 
72
83
  return (
73
84
  <div className={styles.sidebar}>
74
85
  <CaseSidebar
75
86
  user={user}
76
87
  onImageSelect={onImageSelect}
88
+ onOpenCase={onOpenCase}
77
89
  currentCase={currentCase}
78
90
  imageLoaded={imageLoaded}
79
91
  setImageLoaded={setImageLoaded}
@@ -81,12 +93,14 @@ export const Sidebar = ({
81
93
  setFiles={setFiles}
82
94
  onNotesClick={() => setShowNotes(true)}
83
95
  isReadOnly={isReadOnly}
96
+ isArchivedCase={isArchivedCase}
84
97
  isConfirmed={isConfirmed}
85
98
  confirmationSaveVersion={confirmationSaveVersion}
86
99
  selectedFileId={imageId}
87
100
  isUploading={isUploading}
88
101
  onUploadStatusChange={handleUploadStatusChange}
89
102
  onUploadComplete={handleUploadComplete}
103
+ onExportNotification={handleExportNotification}
90
104
  />
91
105
  <Toast
92
106
  message={toastMessage}
@@ -1,6 +1,4 @@
1
- @tailwind base;
2
- @tailwind components;
3
- @tailwind utilities;
1
+ @import "modern-normalize";
4
2
 
5
3
  @layer base {
6
4
  :root {
@@ -0,0 +1,99 @@
1
+ import { useEffect, useState } from 'react';
2
+ import {
3
+ type CasesModalPreferences,
4
+ type CasesModalSortBy,
5
+ type CasesModalConfirmationFilter,
6
+ } from '~/utils/data/case-filters';
7
+
8
+ const CASES_MODAL_PREFERENCES_STORAGE_KEY = 'striae.casesModal.preferences';
9
+
10
+ export const DEFAULT_CASES_MODAL_PREFERENCES: CasesModalPreferences = {
11
+ sortBy: 'recent',
12
+ confirmationFilter: 'all',
13
+ showArchivedOnly: false,
14
+ };
15
+
16
+ function parseStoredPreferences(value: string | null): CasesModalPreferences {
17
+ if (!value) {
18
+ return DEFAULT_CASES_MODAL_PREFERENCES;
19
+ }
20
+
21
+ try {
22
+ const parsed = JSON.parse(value) as Partial<CasesModalPreferences>;
23
+
24
+ const sortBy: CasesModalSortBy =
25
+ parsed.sortBy === 'alphabetical' || parsed.sortBy === 'recent'
26
+ ? parsed.sortBy
27
+ : DEFAULT_CASES_MODAL_PREFERENCES.sortBy;
28
+
29
+ const confirmationFilter: CasesModalConfirmationFilter =
30
+ parsed.confirmationFilter === 'pending' ||
31
+ parsed.confirmationFilter === 'confirmed' ||
32
+ parsed.confirmationFilter === 'none-requested' ||
33
+ parsed.confirmationFilter === 'all'
34
+ ? parsed.confirmationFilter
35
+ : DEFAULT_CASES_MODAL_PREFERENCES.confirmationFilter;
36
+
37
+ const showArchivedOnly =
38
+ typeof parsed.showArchivedOnly === 'boolean'
39
+ ? parsed.showArchivedOnly
40
+ : DEFAULT_CASES_MODAL_PREFERENCES.showArchivedOnly;
41
+
42
+ return {
43
+ sortBy,
44
+ confirmationFilter,
45
+ showArchivedOnly,
46
+ };
47
+ } catch {
48
+ return DEFAULT_CASES_MODAL_PREFERENCES;
49
+ }
50
+ }
51
+
52
+ function loadCasesModalPreferences(): CasesModalPreferences {
53
+ if (typeof window === 'undefined') {
54
+ return DEFAULT_CASES_MODAL_PREFERENCES;
55
+ }
56
+
57
+ return parseStoredPreferences(window.localStorage.getItem(CASES_MODAL_PREFERENCES_STORAGE_KEY));
58
+ }
59
+
60
+ export function useCaseListPreferences() {
61
+ const [preferences, setPreferences] = useState<CasesModalPreferences>(() =>
62
+ loadCasesModalPreferences()
63
+ );
64
+
65
+ useEffect(() => {
66
+ if (typeof window === 'undefined') {
67
+ return;
68
+ }
69
+
70
+ window.localStorage.setItem(
71
+ CASES_MODAL_PREFERENCES_STORAGE_KEY,
72
+ JSON.stringify(preferences)
73
+ );
74
+ }, [preferences]);
75
+
76
+ const setSortBy = (sortBy: CasesModalSortBy) => {
77
+ setPreferences((current) => ({ ...current, sortBy }));
78
+ };
79
+
80
+ const setConfirmationFilter = (confirmationFilter: CasesModalConfirmationFilter) => {
81
+ setPreferences((current) => ({ ...current, confirmationFilter }));
82
+ };
83
+
84
+ const setShowArchivedOnly = (showArchivedOnly: boolean) => {
85
+ setPreferences((current) => ({ ...current, showArchivedOnly }));
86
+ };
87
+
88
+ const resetPreferences = () => {
89
+ setPreferences(DEFAULT_CASES_MODAL_PREFERENCES);
90
+ };
91
+
92
+ return {
93
+ preferences,
94
+ setSortBy,
95
+ setConfirmationFilter,
96
+ setShowArchivedOnly,
97
+ resetPreferences,
98
+ };
99
+ }
@@ -0,0 +1,106 @@
1
+ import { useEffect, useState } from 'react';
2
+ import {
3
+ type FilesModalPreferences,
4
+ type FilesModalSortBy,
5
+ type FilesModalConfirmationFilter,
6
+ type FilesModalClassTypeFilter,
7
+ } from '~/utils/data/file-filters';
8
+
9
+ const FILES_MODAL_PREFERENCES_STORAGE_KEY = 'striae.filesModal.preferences';
10
+
11
+ export const DEFAULT_FILES_MODAL_PREFERENCES: FilesModalPreferences = {
12
+ sortBy: 'recent',
13
+ confirmationFilter: 'all',
14
+ classTypeFilter: 'all',
15
+ };
16
+
17
+ function parseStoredPreferences(value: string | null): FilesModalPreferences {
18
+ if (!value) {
19
+ return DEFAULT_FILES_MODAL_PREFERENCES;
20
+ }
21
+
22
+ try {
23
+ const parsed = JSON.parse(value) as Partial<FilesModalPreferences>;
24
+
25
+ const sortBy: FilesModalSortBy =
26
+ parsed.sortBy === 'filename' ||
27
+ parsed.sortBy === 'confirmation' ||
28
+ parsed.sortBy === 'classType' ||
29
+ parsed.sortBy === 'recent'
30
+ ? parsed.sortBy
31
+ : DEFAULT_FILES_MODAL_PREFERENCES.sortBy;
32
+
33
+ const confirmationFilter: FilesModalConfirmationFilter =
34
+ parsed.confirmationFilter === 'pending' ||
35
+ parsed.confirmationFilter === 'confirmed' ||
36
+ parsed.confirmationFilter === 'none-requested' ||
37
+ parsed.confirmationFilter === 'all'
38
+ ? parsed.confirmationFilter
39
+ : DEFAULT_FILES_MODAL_PREFERENCES.confirmationFilter;
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;
51
+
52
+ return {
53
+ sortBy,
54
+ confirmationFilter,
55
+ classTypeFilter,
56
+ };
57
+ } catch {
58
+ return DEFAULT_FILES_MODAL_PREFERENCES;
59
+ }
60
+ }
61
+
62
+ function loadFilesModalPreferences(): FilesModalPreferences {
63
+ if (typeof window === 'undefined') {
64
+ return DEFAULT_FILES_MODAL_PREFERENCES;
65
+ }
66
+
67
+ return parseStoredPreferences(window.localStorage.getItem(FILES_MODAL_PREFERENCES_STORAGE_KEY));
68
+ }
69
+
70
+ export function useFileListPreferences() {
71
+ const [preferences, setPreferences] = useState<FilesModalPreferences>(() =>
72
+ loadFilesModalPreferences()
73
+ );
74
+
75
+ useEffect(() => {
76
+ if (typeof window === 'undefined') {
77
+ return;
78
+ }
79
+
80
+ window.localStorage.setItem(FILES_MODAL_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences));
81
+ }, [preferences]);
82
+
83
+ const setSortBy = (sortBy: FilesModalSortBy) => {
84
+ setPreferences((current) => ({ ...current, sortBy }));
85
+ };
86
+
87
+ const setConfirmationFilter = (confirmationFilter: FilesModalConfirmationFilter) => {
88
+ setPreferences((current) => ({ ...current, confirmationFilter }));
89
+ };
90
+
91
+ const setClassTypeFilter = (classTypeFilter: FilesModalClassTypeFilter) => {
92
+ setPreferences((current) => ({ ...current, classTypeFilter }));
93
+ };
94
+
95
+ const resetPreferences = () => {
96
+ setPreferences(DEFAULT_FILES_MODAL_PREFERENCES);
97
+ };
98
+
99
+ return {
100
+ preferences,
101
+ setSortBy,
102
+ setConfirmationFilter,
103
+ setClassTypeFilter,
104
+ resetPreferences,
105
+ };
106
+ }
@@ -85,11 +85,13 @@ export const useOverlayDismiss = ({
85
85
  }
86
86
  }, [closeOnBackdrop, requestClose]);
87
87
 
88
+ const isBackdropDismissInteractive = closeOnBackdrop && canDismiss;
89
+
88
90
  const overlayProps = {
89
- role: 'button' as const,
90
- tabIndex: 0,
91
- onMouseDown: handleOverlayMouseDown,
92
- onKeyDown: handleOverlayKeyDown,
91
+ role: isBackdropDismissInteractive ? 'button' : 'presentation',
92
+ tabIndex: isBackdropDismissInteractive ? 0 : undefined,
93
+ onMouseDown: isBackdropDismissInteractive ? handleOverlayMouseDown : undefined,
94
+ onKeyDown: isBackdropDismissInteractive ? handleOverlayKeyDown : undefined,
93
95
  style: { cursor: 'default' as const },
94
96
  };
95
97
 
package/app/root.tsx CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  import { AuthProvider } from '~/components/auth/auth-provider';
17
17
  import { auth } from '~/services/firebase';
18
18
  import styles from '~/styles/root.module.css';
19
- import './tailwind.css';
19
+ import './global.css';
20
20
 
21
21
  export const links: LinksFunction = () => [
22
22
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
@@ -732,6 +732,9 @@ export const Striae = ({ user }: StriaePage) => {
732
732
  <SidebarContainer
733
733
  user={user}
734
734
  onImageSelect={handleImageSelect}
735
+ onOpenCase={() => {
736
+ void handleOpenCaseModal();
737
+ }}
735
738
  imageId={imageId}
736
739
  currentCase={currentCase}
737
740
  imageLoaded={imageLoaded}
@@ -742,6 +745,7 @@ export const Striae = ({ user }: StriaePage) => {
742
745
  setShowNotes={setShowNotes}
743
746
  onAnnotationRefresh={refreshAnnotationData}
744
747
  isReadOnly={isReadOnlyCase}
748
+ isArchivedCase={archiveDetails.archived}
745
749
  isConfirmed={!!annotationData?.confirmationData}
746
750
  confirmationSaveVersion={confirmationSaveVersion}
747
751
  isUploading={isUploading}
@@ -797,6 +801,7 @@ export const Striae = ({ user }: StriaePage) => {
797
801
  }}
798
802
  currentCase={currentCase || ''}
799
803
  user={user}
804
+ confirmationSaveVersion={confirmationSaveVersion}
800
805
  />
801
806
  <FilesModal
802
807
  isOpen={isFilesModalOpen}
@@ -809,6 +814,7 @@ export const Striae = ({ user }: StriaePage) => {
809
814
  setFiles={setFiles}
810
815
  isReadOnly={isReadOnlyCase}
811
816
  selectedFileId={imageId}
817
+ confirmationSaveVersion={confirmationSaveVersion}
812
818
  />
813
819
  <NotesEditorModal
814
820
  isOpen={showNotes}
@@ -819,6 +825,7 @@ export const Striae = ({ user }: StriaePage) => {
819
825
  onAnnotationRefresh={refreshAnnotationData}
820
826
  originalFileName={files.find(file => file.id === imageId)?.originalFilename}
821
827
  isUploading={isUploading}
828
+ showNotification={showNotification}
822
829
  />
823
830
  <CaseExport
824
831
  isOpen={isCaseExportModalOpen}
@@ -1067,7 +1067,7 @@ export class AuditService {
1067
1067
 
1068
1068
  const bundledEntries = caseData.bundledAuditTrail?.entries;
1069
1069
  if (!Array.isArray(bundledEntries)) {
1070
- return [];
1070
+ return null;
1071
1071
  }
1072
1072
 
1073
1073
  const sortedEntries = sortAuditEntriesNewestFirst(bundledEntries);
@@ -1087,7 +1087,7 @@ export class AuditService {
1087
1087
  private async getAuditEntries(params: AuditQueryParams, requestingUser?: User): Promise<ValidationAuditEntry[]> {
1088
1088
  try {
1089
1089
  const bundledArchivedEntries = await this.getBundledArchivedCaseAuditEntries(params, requestingUser);
1090
- if (bundledArchivedEntries) {
1090
+ if (bundledArchivedEntries !== null) {
1091
1091
  return bundledArchivedEntries;
1092
1092
  }
1093
1093
 
@@ -118,7 +118,7 @@ export const buildCaseArchiveAuditParams = (
118
118
  workflowPhase: 'casework',
119
119
  caseDetails: {
120
120
  newCaseName: input.caseName,
121
- deleteReason: input.archiveReason,
121
+ archiveReason: input.archiveReason,
122
122
  totalFiles: input.totalFiles,
123
123
  lastModified: archivedAt,
124
124
  },
@@ -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;
@@ -202,6 +202,7 @@ export interface CaseAuditDetails {
202
202
  createdDate?: string;
203
203
  lastModified?: string;
204
204
  deleteReason?: string;
205
+ archiveReason?: string;
205
206
  backupCreated?: boolean;
206
207
  }
207
208
 
@@ -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
+ }