@striae-org/striae 4.1.0 → 4.2.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 (124) hide show
  1. package/.env.example +8 -0
  2. package/LICENSE +1 -1
  3. package/app/components/actions/case-export/core-export.ts +14 -8
  4. package/app/components/actions/case-export/data-processing.ts +1 -0
  5. package/app/components/actions/case-export/download-handlers.ts +7 -0
  6. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  7. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  8. package/app/components/actions/case-import/orchestrator.ts +78 -32
  9. package/app/components/actions/case-import/storage-operations.ts +97 -8
  10. package/app/components/actions/case-import/zip-processing.ts +159 -86
  11. package/app/components/actions/case-manage.ts +463 -8
  12. package/app/components/actions/confirm-export.ts +9 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +19 -8
  15. package/app/components/audit/user-audit.module.css +21 -0
  16. package/app/components/audit/viewer/audit-entries-list.tsx +12 -2
  17. package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
  18. package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
  19. package/app/components/audit/viewer/use-audit-viewer-data.ts +24 -1
  20. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  21. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  22. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  23. package/app/components/canvas/canvas.module.css +64 -54
  24. package/app/components/canvas/canvas.tsx +14 -16
  25. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  26. package/app/components/canvas/confirmation/confirmation.tsx +12 -14
  27. package/app/components/colors/colors.module.css +4 -3
  28. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  29. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  30. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  31. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  32. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  33. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  34. package/app/components/navbar/navbar.module.css +447 -0
  35. package/app/components/navbar/navbar.tsx +402 -0
  36. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  37. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
  38. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  39. package/app/components/sidebar/case-export/case-export.tsx +8 -46
  40. package/app/components/sidebar/case-import/case-import.module.css +23 -0
  41. package/app/components/sidebar/case-import/case-import.tsx +64 -16
  42. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  43. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  44. package/app/components/sidebar/cases/case-sidebar.tsx +68 -588
  45. package/app/components/sidebar/cases/cases-modal.module.css +1 -0
  46. package/app/components/sidebar/cases/cases-modal.tsx +82 -43
  47. package/app/components/sidebar/cases/cases.module.css +82 -21
  48. package/app/components/sidebar/files/files-modal.module.css +1 -0
  49. package/app/components/sidebar/files/files-modal.tsx +49 -52
  50. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  51. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +187 -138
  52. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  53. package/app/components/sidebar/notes/notes-editor-modal.tsx +64 -0
  54. package/app/components/sidebar/notes/notes.module.css +170 -1
  55. package/app/components/sidebar/sidebar-container.tsx +16 -28
  56. package/app/components/sidebar/sidebar.module.css +5 -69
  57. package/app/components/sidebar/sidebar.tsx +27 -125
  58. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  59. package/app/components/user/inactivity-warning.module.css +1 -0
  60. package/app/components/user/inactivity-warning.tsx +15 -2
  61. package/app/components/user/manage-profile.tsx +23 -10
  62. package/app/{tailwind.css → global.css} +1 -3
  63. package/app/hooks/useOverlayDismiss.ts +54 -4
  64. package/app/root.tsx +1 -1
  65. package/app/routes/auth/login.tsx +785 -774
  66. package/app/routes/striae/striae.module.css +10 -3
  67. package/app/routes/striae/striae.tsx +475 -30
  68. package/app/services/audit/audit.service.ts +173 -27
  69. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  70. package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
  71. package/app/services/audit/builders/index.ts +1 -0
  72. package/app/types/audit.ts +4 -1
  73. package/app/types/case.ts +29 -0
  74. package/app/types/import.ts +3 -0
  75. package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
  76. package/app/utils/data/data-operations.ts +17 -861
  77. package/app/utils/data/index.ts +11 -1
  78. package/app/utils/data/operations/batch-operations.ts +113 -0
  79. package/app/utils/data/operations/case-operations.ts +168 -0
  80. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  81. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  82. package/app/utils/data/operations/index.ts +7 -0
  83. package/app/utils/data/operations/signing-operations.ts +225 -0
  84. package/app/utils/data/operations/types.ts +42 -0
  85. package/app/utils/data/operations/validation-operations.ts +48 -0
  86. package/app/utils/data/permissions.ts +16 -1
  87. package/app/utils/forensics/audit-export-signature.ts +5 -1
  88. package/app/utils/forensics/confirmation-signature.ts +3 -0
  89. package/app/utils/forensics/export-verification.ts +426 -22
  90. package/functions/api/_shared/firebase-auth.ts +2 -7
  91. package/functions/api/image/[[path]].ts +20 -23
  92. package/functions/api/pdf/[[path]].ts +27 -8
  93. package/package.json +7 -12
  94. package/scripts/deploy-primershear-emails.sh +2 -1
  95. package/worker-configuration.d.ts +3 -3
  96. package/workers/audit-worker/package.json +1 -1
  97. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  98. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  99. package/workers/data-worker/package.json +1 -1
  100. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  101. package/workers/data-worker/wrangler.jsonc.example +1 -1
  102. package/workers/image-worker/package.json +1 -1
  103. package/workers/image-worker/src/image-worker.example.ts +16 -5
  104. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  105. package/workers/image-worker/wrangler.jsonc.example +1 -1
  106. package/workers/keys-worker/package.json +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/package.json +1 -1
  110. package/workers/pdf-worker/src/formats/format-striae.ts +9 -14
  111. package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
  112. package/workers/pdf-worker/src/report-types.ts +3 -3
  113. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  114. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  115. package/workers/user-worker/package.json +1 -1
  116. package/workers/user-worker/src/user-worker.example.ts +17 -0
  117. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  118. package/workers/user-worker/wrangler.jsonc.example +1 -1
  119. package/wrangler.toml.example +1 -1
  120. package/NOTICE +0 -13
  121. package/app/components/sidebar/notes/notes-modal.tsx +0 -53
  122. package/postcss.config.js +0 -6
  123. package/public/.well-known/keybase.txt +0 -56
  124. package/tailwind.config.ts +0 -22
@@ -11,6 +11,7 @@
11
11
  }
12
12
 
13
13
  .modal {
14
+ position: relative;
14
15
  background: var(--backgroundLight);
15
16
  border-radius: var(--spaceXS);
16
17
  width: 90%;
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
2
2
  import type { User } from 'firebase/auth';
3
3
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
4
4
  import { listCases } from '~/components/actions/case-manage';
5
- import { getFileAnnotations } from '~/utils/data';
5
+ import { ensureCaseConfirmationSummary, getConfirmationSummaryDocument } from '~/utils/data';
6
6
  import { fetchFiles } from '~/components/actions/image-manage';
7
7
  import styles from './cases-modal.module.css';
8
8
 
@@ -12,16 +12,25 @@ interface CasesModalProps {
12
12
  onSelectCase: (caseNum: string) => void;
13
13
  currentCase: string;
14
14
  user: User;
15
+ confirmationSaveVersion?: number;
15
16
  }
16
17
 
17
- export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }: CasesModalProps) => {
18
+ export const CasesModal = ({
19
+ isOpen,
20
+ onClose,
21
+ onSelectCase,
22
+ currentCase,
23
+ user,
24
+ confirmationSaveVersion = 0
25
+ }: CasesModalProps) => {
18
26
  const [cases, setCases] = useState<string[]>([]);
19
27
  const [isLoading, setIsLoading] = useState(false);
20
28
  const [error, setError] = useState<string>('');
21
29
  const [currentPage, setCurrentPage] = useState(0);
22
30
  const {
23
- handleOverlayMouseDown,
24
- handleOverlayKeyDown
31
+ requestClose,
32
+ overlayProps,
33
+ getCloseButtonProps
25
34
  } = useOverlayDismiss({
26
35
  isOpen,
27
36
  onClose
@@ -69,6 +78,43 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
69
78
 
70
79
  // Fetch confirmation status only for currently visible paginated cases
71
80
  useEffect(() => {
81
+ let isCancelled = false;
82
+
83
+ const loadConfirmationSummary = async () => {
84
+ if (!isOpen) {
85
+ return;
86
+ }
87
+
88
+ const summary = await getConfirmationSummaryDocument(user).catch((err) => {
89
+ console.error('Failed to load confirmation summary:', err);
90
+ return null;
91
+ });
92
+
93
+ if (!summary || isCancelled) {
94
+ return;
95
+ }
96
+
97
+ const statuses: { [caseNum: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
98
+ for (const [caseNum, entry] of Object.entries(summary.cases)) {
99
+ statuses[caseNum] = {
100
+ includeConfirmation: entry.includeConfirmation,
101
+ isConfirmed: entry.isConfirmed
102
+ };
103
+ }
104
+
105
+ setCaseConfirmationStatus(statuses);
106
+ };
107
+
108
+ loadConfirmationSummary();
109
+
110
+ return () => {
111
+ isCancelled = true;
112
+ };
113
+ }, [isOpen, user, confirmationSaveVersion]);
114
+
115
+ useEffect(() => {
116
+ let isCancelled = false;
117
+
72
118
  const fetchCaseConfirmationStatuses = async () => {
73
119
  const visibleCases = cases.slice(
74
120
  currentPage * CASES_PER_PAGE,
@@ -79,34 +125,21 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
79
125
  return;
80
126
  }
81
127
 
82
- // Fetch case statuses in parallel for only visible cases
83
- const caseStatusPromises = visibleCases.map(async (caseNum) => {
128
+ const missingCaseNumbers = visibleCases.filter((caseNum) => !caseConfirmationStatus[caseNum]);
129
+ if (missingCaseNumbers.length === 0) {
130
+ return;
131
+ }
132
+
133
+ const caseStatusPromises = missingCaseNumbers.map(async (caseNum) => {
84
134
  try {
85
135
  const files = await fetchFiles(user, caseNum, { skipValidation: true });
86
-
87
- // Fetch annotations for each file in the case (in parallel)
88
- const fileStatuses = await Promise.all(
89
- files.map(async (file) => {
90
- try {
91
- const annotations = await getFileAnnotations(user, caseNum, file.id);
92
- return {
93
- includeConfirmation: annotations?.includeConfirmation ?? false,
94
- isConfirmed: !!(annotations?.includeConfirmation && annotations?.confirmationData),
95
- };
96
- } catch {
97
- return { includeConfirmation: false, isConfirmed: false };
98
- }
99
- })
100
- );
101
-
102
- // Calculate case status
103
- const filesRequiringConfirmation = fileStatuses.filter(s => s.includeConfirmation);
104
- const allConfirmedFiles = filesRequiringConfirmation.every(s => s.isConfirmed);
136
+
137
+ const caseSummary = await ensureCaseConfirmationSummary(user, caseNum, files);
105
138
 
106
139
  return {
107
140
  caseNum,
108
- includeConfirmation: filesRequiringConfirmation.length > 0,
109
- isConfirmed: filesRequiringConfirmation.length > 0 ? allConfirmedFiles : false,
141
+ includeConfirmation: caseSummary.includeConfirmation,
142
+ isConfirmed: caseSummary.isConfirmed,
110
143
  };
111
144
  } catch (err) {
112
145
  console.error(`Error fetching confirmation status for case ${caseNum}:`, err);
@@ -121,36 +154,42 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
121
154
  // Wait for all case status fetches to complete
122
155
  const results = await Promise.all(caseStatusPromises);
123
156
 
124
- // Build the statuses map from results
125
- const statuses: { [caseNum: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
126
- results.forEach((result) => {
127
- statuses[result.caseNum] = {
128
- includeConfirmation: result.includeConfirmation,
129
- isConfirmed: result.isConfirmed,
130
- };
131
- });
157
+ if (isCancelled) {
158
+ return;
159
+ }
132
160
 
133
- setCaseConfirmationStatus(statuses);
161
+ setCaseConfirmationStatus((previous) => {
162
+ const next = { ...previous };
163
+ results.forEach((result) => {
164
+ next[result.caseNum] = {
165
+ includeConfirmation: result.includeConfirmation,
166
+ isConfirmed: result.isConfirmed,
167
+ };
168
+ });
169
+
170
+ return next;
171
+ });
134
172
  };
135
173
 
136
174
  fetchCaseConfirmationStatuses();
137
- }, [isOpen, currentPage, cases, user]);
175
+
176
+ return () => {
177
+ isCancelled = true;
178
+ };
179
+ }, [isOpen, currentPage, cases, user, caseConfirmationStatus]);
138
180
 
139
181
  if (!isOpen) return null;
140
182
 
141
183
  return (
142
184
  <div
143
185
  className={styles.modalOverlay}
144
- onMouseDown={handleOverlayMouseDown}
145
- onKeyDown={handleOverlayKeyDown}
146
- role="button"
147
- tabIndex={0}
148
186
  aria-label="Close cases dialog"
187
+ {...overlayProps}
149
188
  >
150
189
  <div className={styles.modal}>
151
190
  <header className={styles.modalHeader}>
152
191
  <h2>All Cases</h2>
153
- <button onClick={onClose} className={styles.closeButton}>&times;</button>
192
+ <button className={styles.closeButton} {...getCloseButtonProps({ ariaLabel: 'Close cases dialog' })}>&times;</button>
154
193
  </header>
155
194
 
156
195
  <div className={styles.modalContent}>
@@ -178,7 +217,7 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
178
217
  className={`${styles.caseItem} ${currentCase === caseNum ? styles.active : ''} ${confirmationClass}`}
179
218
  onClick={() => {
180
219
  onSelectCase(caseNum);
181
- onClose();
220
+ requestClose();
182
221
  }}
183
222
  >
184
223
  {caseNum}
@@ -1,6 +1,11 @@
1
1
  /* Case Management */
2
2
  .caseSection {
3
- margin-bottom: 2rem;
3
+ display: flex;
4
+ flex-direction: column;
5
+ gap: 0.75rem;
6
+ height: 100%;
7
+ min-height: 0;
8
+ margin-bottom: 0;
4
9
  }
5
10
 
6
11
  .caseSection h4 {
@@ -141,17 +146,18 @@
141
146
  }
142
147
 
143
148
  .filesModalSection {
144
- margin: 1rem 0;
149
+ margin: 0.25rem 0 0;
145
150
  }
146
151
 
147
152
  .filesModalButton {
148
153
  width: 100%;
149
- padding: 0.75rem;
154
+ padding: 0.625rem 0.75rem;
150
155
  background-color: #17a2b8;
151
156
  color: white;
152
157
  border: none;
153
158
  border-radius: 6px;
154
159
  font-weight: 500;
160
+ font-size: 0.9rem;
155
161
  cursor: pointer;
156
162
  transition: all 0.2s;
157
163
  box-sizing: border-box;
@@ -179,16 +185,57 @@
179
185
 
180
186
  /* Files Section */
181
187
  .filesSection {
182
- margin-top: 2rem;
188
+ display: flex;
189
+ flex: 1;
190
+ flex-direction: column;
191
+ gap: 0.625rem;
192
+ min-height: 0;
193
+ margin-top: 0.25rem;
183
194
  }
184
195
 
185
196
  .filesSection h4 {
186
- margin-bottom: 1rem;
187
- font-size: 1.3rem;
197
+ margin-bottom: 0;
198
+ font-size: 1.1rem;
188
199
  font-weight: 900;
189
200
  text-align: center;
190
201
  }
191
202
 
203
+ .emptyCaseHeader {
204
+ margin-top: 0.75rem;
205
+ }
206
+
207
+ .openCaseButton {
208
+ width: 100%;
209
+ padding: 0.75rem 1rem;
210
+ background-color: var(--primary);
211
+ color: white;
212
+ border: none;
213
+ border-radius: 6px;
214
+ font-weight: 600;
215
+ font-size: 0.95rem;
216
+ cursor: pointer;
217
+ transition: all 0.2s;
218
+ box-sizing: border-box;
219
+ }
220
+
221
+ .openCaseButton:hover {
222
+ background-color: color-mix(in lab, var(--primary) 85%, var(--black));
223
+ }
224
+
225
+ .fileListPlaceholder {
226
+ display: flex;
227
+ align-items: center;
228
+ justify-content: center;
229
+ min-height: 5rem;
230
+ padding: 0.875rem;
231
+ border: 1px dashed #dee2e6;
232
+ border-radius: 6px;
233
+ background: #f8f9fa;
234
+ color: #6c757d;
235
+ font-size: 0.875rem;
236
+ text-align: center;
237
+ }
238
+
192
239
  .emptyState {
193
240
  color: #6c757d;
194
241
  font-size: 0.9rem;
@@ -207,10 +254,20 @@
207
254
  border: 1px solid #dee2e6;
208
255
  border-radius: 6px;
209
256
  overflow: hidden;
210
- max-height: calc(5 * 3.5rem);
257
+ flex: 1;
258
+ min-height: 0;
259
+ max-height: none;
211
260
  overflow-y: auto;
212
261
  }
213
262
 
263
+ .fileListMessage {
264
+ padding: 0.875rem;
265
+ color: #6c757d;
266
+ font-size: 0.875rem;
267
+ text-align: center;
268
+ background: #f8f9fa;
269
+ }
270
+
214
271
  .fileList::-webkit-scrollbar {
215
272
  width: 6px;
216
273
  }
@@ -231,7 +288,7 @@
231
288
  .fileItem {
232
289
  display: flex;
233
290
  align-items: center;
234
- padding: 0.5rem 1rem;
291
+ padding: 0.375rem 0.625rem;
235
292
  border-bottom: 1px solid #dee2e6;
236
293
  background: white;
237
294
  transition: background-color 0.2s;
@@ -240,13 +297,14 @@
240
297
  .fileButton {
241
298
  flex: 1;
242
299
  text-align: left;
243
- padding: 0.5rem;
300
+ padding: 0.375rem;
244
301
  background: none;
245
302
  border: none;
246
303
  cursor: pointer;
247
304
  overflow: hidden;
248
305
  text-overflow: ellipsis;
249
306
  white-space: nowrap;
307
+ font-size: 0.875rem;
250
308
  }
251
309
 
252
310
  .fileItem:last-child {
@@ -335,14 +393,14 @@
335
393
  background: none;
336
394
  border: none;
337
395
  color: #dc3545;
338
- font-size: 1.2rem;
396
+ font-size: 1rem;
339
397
  cursor: pointer;
340
- padding: 0.5rem;
398
+ padding: 0.375rem;
341
399
  display: flex;
342
400
  align-items: center;
343
401
  justify-content: center;
344
- min-width: 32px;
345
- height: 32px;
402
+ min-width: 28px;
403
+ height: 28px;
346
404
  }
347
405
 
348
406
  .deleteButton:hover {
@@ -404,18 +462,19 @@
404
462
  /* Notes Toggle */
405
463
 
406
464
  .sidebarToggle {
407
- margin-bottom: 1rem;
408
- padding: 1rem 1rem;
465
+ margin-top: 0;
466
+ padding: 0;
409
467
  }
410
468
 
411
469
  .sidebarToggle button {
412
470
  width: 100%;
413
- padding: 0.75rem;
471
+ padding: 0.625rem 0.75rem;
414
472
  background-color: var(--primary);
415
473
  color: white;
416
474
  border: none;
417
475
  border-radius: 6px;
418
476
  font-weight: 500;
477
+ font-size: 0.9rem;
419
478
  cursor: pointer;
420
479
  transition: all 0.2s;
421
480
  }
@@ -637,33 +696,35 @@
637
696
  /* Case Header Container */
638
697
  .caseHeader {
639
698
  /* Normal case header styling (no background) */
699
+ margin-top: 0.75rem;
640
700
  }
641
701
 
642
702
  .readOnlyContainer {
643
703
  background: #fff3cd;
644
704
  border: 1px solid #ffeaa7;
645
705
  border-radius: 4px;
646
- padding: 0.75rem;
647
- margin-bottom: 1rem;
706
+ padding: 0.625rem 0.75rem;
707
+ margin-top: 0.75rem;
708
+ margin-bottom: 0;
648
709
  }
649
710
 
650
711
  .caseNumber {
651
712
  margin: 0;
652
- font-size: 1rem;
713
+ font-size: 0.95rem;
653
714
  font-weight: 600;
654
715
  }
655
716
 
656
717
  /* Case Confirmation Status Indicators */
657
718
  .caseNumber.caseNotConfirmed {
658
719
  background-color: #fffacd;
659
- padding: 0.75rem;
720
+ padding: 0.625rem 0.75rem;
660
721
  border-radius: 4px;
661
722
  margin: 0;
662
723
  }
663
724
 
664
725
  .caseNumber.caseConfirmed {
665
726
  background-color: #c8e6c9;
666
- padding: 0.75rem;
727
+ padding: 0.625rem 0.75rem;
667
728
  border-radius: 4px;
668
729
  margin: 0;
669
730
  }
@@ -11,6 +11,7 @@
11
11
  }
12
12
 
13
13
  .modal {
14
+ position: relative;
14
15
  background: var(--backgroundLight);
15
16
  border-radius: var(--spaceXS);
16
17
  width: 90%;
@@ -3,7 +3,7 @@ import { useState, useContext, useEffect } from 'react';
3
3
  import { AuthContext } from '~/contexts/auth.context';
4
4
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
5
5
  import { deleteFile } from '~/components/actions/image-manage';
6
- import { getFileAnnotations } from '~/utils/data';
6
+ import { ensureCaseConfirmationSummary } from '~/utils/data';
7
7
  import { type FileData } from '~/types';
8
8
  import styles from './files-modal.module.css';
9
9
 
@@ -16,6 +16,7 @@ interface FilesModalProps {
16
16
  setFiles: React.Dispatch<React.SetStateAction<FileData[]>>;
17
17
  isReadOnly?: boolean;
18
18
  selectedFileId?: string;
19
+ confirmationSaveVersion?: number;
19
20
  }
20
21
 
21
22
  const FILES_PER_PAGE = 10;
@@ -28,15 +29,26 @@ interface FileConfirmationStatus {
28
29
  };
29
30
  }
30
31
 
31
- export const FilesModal = ({ isOpen, onClose, onFileSelect, currentCase, files, setFiles, isReadOnly = false, selectedFileId }: FilesModalProps) => {
32
+ export const FilesModal = ({
33
+ isOpen,
34
+ onClose,
35
+ onFileSelect,
36
+ currentCase,
37
+ files,
38
+ setFiles,
39
+ isReadOnly = false,
40
+ selectedFileId,
41
+ confirmationSaveVersion = 0
42
+ }: FilesModalProps) => {
32
43
  const { user } = useContext(AuthContext);
33
44
  const [error, setError] = useState<string | null>(null);
34
45
  const [currentPage, setCurrentPage] = useState(0);
35
46
  const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
36
47
  const [fileConfirmationStatus, setFileConfirmationStatus] = useState<FileConfirmationStatus>({});
37
48
  const {
38
- handleOverlayMouseDown,
39
- handleOverlayKeyDown
49
+ requestClose,
50
+ overlayProps,
51
+ getCloseButtonProps
40
52
  } = useOverlayDismiss({
41
53
  isOpen,
42
54
  onClose
@@ -47,58 +59,40 @@ export const FilesModal = ({ isOpen, onClose, onFileSelect, currentCase, files,
47
59
  const endIndex = startIndex + FILES_PER_PAGE;
48
60
  const currentFiles = files.slice(startIndex, endIndex);
49
61
 
50
- // Fetch confirmation status only for currently visible paginated files
62
+ // Hydrate confirmation status from shared summary document.
51
63
  useEffect(() => {
52
- const fetchConfirmationStatuses = async () => {
53
- const visibleFiles = files.slice(
54
- currentPage * FILES_PER_PAGE,
55
- currentPage * FILES_PER_PAGE + FILES_PER_PAGE
56
- );
64
+ let isCancelled = false;
57
65
 
58
- if (!isOpen || !currentCase || !user || visibleFiles.length === 0) {
66
+ const fetchConfirmationStatuses = async () => {
67
+ if (!isOpen || !currentCase || !user || files.length === 0) {
68
+ if (!isCancelled) {
69
+ setFileConfirmationStatus({});
70
+ }
59
71
  return;
60
72
  }
61
73
 
62
- // Fetch annotations in parallel for only visible files
63
- const annotationPromises = visibleFiles.map(async (file) => {
64
- try {
65
- const annotations = await getFileAnnotations(user, currentCase, file.id);
66
- return {
67
- fileId: file.id,
68
- includeConfirmation: annotations?.includeConfirmation ?? false,
69
- isConfirmed: !!(annotations?.includeConfirmation && annotations?.confirmationData),
70
- };
71
- } catch (err) {
72
- console.error(`Error fetching annotations for file ${file.id}:`, err);
73
- return {
74
- fileId: file.id,
75
- includeConfirmation: false,
76
- isConfirmed: false,
77
- };
78
- }
74
+ const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files).catch((err) => {
75
+ console.error(`Error fetching confirmation summary for case ${currentCase}:`, err);
76
+ return null;
79
77
  });
80
78
 
81
- // Wait for all fetches to complete
82
- const results = await Promise.all(annotationPromises);
83
-
84
- // Build the statuses map from results
85
- const statuses: FileConfirmationStatus = {};
86
- results.forEach((result) => {
87
- statuses[result.fileId] = {
88
- includeConfirmation: result.includeConfirmation,
89
- isConfirmed: result.isConfirmed,
90
- };
91
- });
79
+ if (!caseSummary || isCancelled) {
80
+ return;
81
+ }
92
82
 
93
- setFileConfirmationStatus(statuses);
83
+ setFileConfirmationStatus(caseSummary.filesById);
94
84
  };
95
85
 
96
86
  fetchConfirmationStatuses();
97
- }, [isOpen, currentCase, currentPage, files, user]);
87
+
88
+ return () => {
89
+ isCancelled = true;
90
+ };
91
+ }, [isOpen, currentCase, files, user, confirmationSaveVersion]);
98
92
 
99
93
  const handleFileSelect = (file: FileData) => {
100
94
  onFileSelect?.(file);
101
- onClose();
95
+ requestClose();
102
96
  };
103
97
 
104
98
  const handleDeleteFile = async (fileId: string, event: React.MouseEvent) => {
@@ -116,10 +110,20 @@ export const FilesModal = ({ isOpen, onClose, onFileSelect, currentCase, files,
116
110
  setDeletingFileId(fileId);
117
111
 
118
112
  try {
119
- await deleteFile(user, currentCase, fileId);
113
+ const deleteResult = await deleteFile(user, currentCase, fileId);
120
114
  // Remove the deleted file from the list
121
115
  const updatedFiles = files.filter(f => f.id !== fileId);
122
116
  setFiles(updatedFiles);
117
+ setFileConfirmationStatus((previous) => {
118
+ const next = { ...previous };
119
+ delete next[fileId];
120
+ return next;
121
+ });
122
+
123
+ if (deleteResult.imageMissing) {
124
+ setError(`File record deleted. Image asset "${deleteResult.fileName}" was not found and was skipped.`);
125
+ setTimeout(() => setError(null), 4000);
126
+ }
123
127
 
124
128
  // Adjust page if needed
125
129
  const newTotalPages = Math.ceil(updatedFiles.length / FILES_PER_PAGE);
@@ -161,20 +165,13 @@ export const FilesModal = ({ isOpen, onClose, onFileSelect, currentCase, files,
161
165
  return (
162
166
  <div
163
167
  className={styles.modalOverlay}
164
- onMouseDown={handleOverlayMouseDown}
165
- onKeyDown={handleOverlayKeyDown}
166
- role="button"
167
- tabIndex={0}
168
168
  aria-label="Close files dialog"
169
+ {...overlayProps}
169
170
  >
170
171
  <div className={styles.modal}>
171
172
  <div className={styles.modalHeader}>
172
173
  <h2>Files in Case {currentCase}</h2>
173
- <button
174
- className={styles.closeButton}
175
- onClick={onClose}
176
- aria-label="Close modal"
177
- >
174
+ <button className={styles.closeButton} {...getCloseButtonProps({ ariaLabel: 'Close files dialog' })}>
178
175
  ×
179
176
  </button>
180
177
  </div>
@@ -0,0 +1,82 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
+ import styles from './notes.module.css';
4
+
5
+ interface AddlNotesModalProps {
6
+ isOpen: boolean;
7
+ onClose: () => void;
8
+ notes: string;
9
+ onSave: (notes: string) => void;
10
+ showNotification?: (message: string, type: 'success' | 'error' | 'warning') => void;
11
+ }
12
+
13
+ export const AddlNotesModal = ({ isOpen, onClose, notes, onSave, showNotification }: AddlNotesModalProps) => {
14
+ const [tempNotes, setTempNotes] = useState(notes);
15
+ const [isSaving, setIsSaving] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (isOpen) {
19
+ setTempNotes(notes);
20
+ }
21
+ }, [isOpen, notes]);
22
+ const {
23
+ requestClose,
24
+ overlayProps,
25
+ getCloseButtonProps
26
+ } = useOverlayDismiss({
27
+ isOpen,
28
+ onClose
29
+ });
30
+
31
+ if (!isOpen) return null;
32
+
33
+ const handleSave = async () => {
34
+ setIsSaving(true);
35
+ try {
36
+ await Promise.resolve(onSave(tempNotes));
37
+ showNotification?.('Notes saved successfully.', 'success');
38
+ requestClose();
39
+ } catch (error) {
40
+ const message = error instanceof Error ? error.message : 'Failed to save notes.';
41
+ showNotification?.(message, 'error');
42
+ } finally {
43
+ setIsSaving(false);
44
+ }
45
+ };
46
+
47
+ return (
48
+ <div
49
+ className={styles.modalOverlay}
50
+ aria-label="Close notes dialog"
51
+ {...overlayProps}
52
+ >
53
+ <div className={styles.modal}>
54
+ <button {...getCloseButtonProps({ ariaLabel: 'Close notes dialog' })}>×</button>
55
+ <h5 className={styles.modalTitle}>Additional Notes</h5>
56
+ <textarea
57
+ value={tempNotes}
58
+ onChange={(e) => setTempNotes(e.target.value)}
59
+ className={styles.modalTextarea}
60
+ placeholder="Enter additional notes..."
61
+ />
62
+ <div className={styles.modalButtons}>
63
+ <button
64
+ onClick={handleSave}
65
+ className={styles.saveButton}
66
+ disabled={isSaving}
67
+ aria-busy={isSaving}
68
+ >
69
+ {isSaving ? 'Saving...' : 'Save'}
70
+ </button>
71
+ <button
72
+ onClick={requestClose}
73
+ className={styles.cancelButton}
74
+ disabled={isSaving}
75
+ >
76
+ Cancel
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ );
82
+ };