@striae-org/striae 4.2.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 (65) 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/navbar.tsx +34 -9
  9. package/app/components/sidebar/cases/case-sidebar.tsx +44 -70
  10. package/app/components/sidebar/cases/cases-modal.tsx +76 -35
  11. package/app/components/sidebar/cases/cases.module.css +20 -0
  12. package/app/components/sidebar/files/files-modal.tsx +37 -39
  13. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  14. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +37 -74
  15. package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
  16. package/app/components/sidebar/notes/notes.module.css +27 -11
  17. package/app/components/sidebar/sidebar-container.tsx +1 -0
  18. package/app/components/sidebar/sidebar.tsx +3 -0
  19. package/app/{tailwind.css → global.css} +1 -3
  20. package/app/hooks/useOverlayDismiss.ts +6 -4
  21. package/app/root.tsx +1 -1
  22. package/app/routes/striae/striae.tsx +6 -0
  23. package/app/services/audit/audit.service.ts +2 -2
  24. package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
  25. package/app/types/audit.ts +1 -0
  26. package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
  27. package/app/utils/data/data-operations.ts +17 -861
  28. package/app/utils/data/index.ts +11 -1
  29. package/app/utils/data/operations/batch-operations.ts +113 -0
  30. package/app/utils/data/operations/case-operations.ts +168 -0
  31. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  32. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  33. package/app/utils/data/operations/index.ts +7 -0
  34. package/app/utils/data/operations/signing-operations.ts +225 -0
  35. package/app/utils/data/operations/types.ts +42 -0
  36. package/app/utils/data/operations/validation-operations.ts +48 -0
  37. package/app/utils/forensics/export-verification.ts +40 -111
  38. package/functions/api/_shared/firebase-auth.ts +2 -7
  39. package/functions/api/image/[[path]].ts +20 -23
  40. package/functions/api/pdf/[[path]].ts +27 -8
  41. package/package.json +5 -10
  42. package/scripts/deploy-primershear-emails.sh +1 -1
  43. package/worker-configuration.d.ts +2 -2
  44. package/workers/audit-worker/package.json +1 -1
  45. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  46. package/workers/data-worker/package.json +1 -1
  47. package/workers/data-worker/wrangler.jsonc.example +1 -1
  48. package/workers/image-worker/package.json +1 -1
  49. package/workers/image-worker/src/image-worker.example.ts +16 -5
  50. package/workers/image-worker/wrangler.jsonc.example +1 -1
  51. package/workers/keys-worker/package.json +1 -1
  52. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  53. package/workers/pdf-worker/package.json +1 -1
  54. package/workers/pdf-worker/src/formats/format-striae.ts +1 -7
  55. package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
  56. package/workers/pdf-worker/src/report-types.ts +3 -3
  57. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  58. package/workers/user-worker/package.json +1 -1
  59. package/workers/user-worker/src/user-worker.example.ts +17 -0
  60. package/workers/user-worker/wrangler.jsonc.example +1 -1
  61. package/wrangler.toml.example +1 -1
  62. package/NOTICE +0 -13
  63. package/app/components/sidebar/notes/notes-modal.tsx +0 -52
  64. package/postcss.config.js +0 -6
  65. package/tailwind.config.ts +0 -22
package/LICENSE CHANGED
@@ -175,7 +175,7 @@
175
175
 
176
176
  END OF TERMS AND CONDITIONS
177
177
 
178
- Striae © 2025. All rights reserved.
178
+ © 2025 Stephen J. Lu
179
179
 
180
180
  Licensed under the Apache License, Version 2.0 (the "License");
181
181
  you may not use this file except in compliance with the License.
@@ -11,9 +11,10 @@ import {
11
11
  deleteCaseData,
12
12
  duplicateCaseData,
13
13
  deleteFileAnnotations,
14
- signForensicManifest
14
+ signForensicManifest,
15
+ removeCaseConfirmationSummary
15
16
  } from '~/utils/data';
16
- import { type CaseData, type ReadOnlyCaseData, type FileData, type AuditTrail } from '~/types';
17
+ import { type CaseData, type ReadOnlyCaseData, type FileData, type AuditTrail, type CaseExportData } from '~/types';
17
18
  import { auditService } from '~/services/audit';
18
19
  import { fetchImageApi } from '~/utils/api';
19
20
  import { exportCaseData, formatDateForFilename } from '~/components/actions/case-export';
@@ -569,6 +570,13 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<Delete
569
570
  // Delete case data using centralized function (skip validation since user no longer has access)
570
571
  await deleteCaseData(user, caseNumber, { skipValidation: true });
571
572
 
573
+ // Clean up confirmation status metadata for this case
574
+ try {
575
+ await removeCaseConfirmationSummary(user, caseNumber);
576
+ } catch (summaryError) {
577
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
578
+ }
579
+
572
580
  // Add a small delay before audit logging to reduce rate limiting
573
581
  await new Promise(resolve => setTimeout(resolve, 100));
574
582
 
@@ -593,6 +601,13 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<Delete
593
601
  // Delete case data using centralized function (skip validation since user no longer has access)
594
602
  await deleteCaseData(user, caseNumber, { skipValidation: true });
595
603
 
604
+ // Clean up confirmation status metadata for this case
605
+ try {
606
+ await removeCaseConfirmationSummary(user, caseNumber);
607
+ } catch (summaryError) {
608
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
609
+ }
610
+
596
611
  // Add a small delay before audit logging to reduce rate limiting
597
612
  await new Promise(resolve => setTimeout(resolve, 100));
598
613
 
@@ -736,22 +751,19 @@ export const archiveCase = async (
736
751
  isReadOnly: true,
737
752
  } as CaseData;
738
753
 
739
- await updateCaseData(user, caseNumber, archiveData);
740
-
741
- await auditService.logCaseArchive(
742
- user,
743
- caseNumber,
744
- caseNumber,
745
- archiveReason?.trim() || 'No reason provided',
746
- 'success',
747
- [],
748
- archiveData.files?.length || 0,
749
- archivedAt,
750
- Date.now() - startTime
751
- );
752
-
753
754
  const exportData = await exportCaseData(user, caseNumber, { includeMetadata: true });
754
- const caseJsonContent = JSON.stringify(exportData, null, 2);
755
+ const archivedExportData: CaseExportData = {
756
+ ...exportData,
757
+ metadata: {
758
+ ...exportData.metadata,
759
+ archived: true,
760
+ archivedAt,
761
+ archivedBy: user.uid,
762
+ archivedByDisplay,
763
+ archiveReason: archiveReason?.trim() || undefined,
764
+ },
765
+ };
766
+ const caseJsonContent = JSON.stringify(archivedExportData, null, 2);
755
767
 
756
768
  const JSZip = (await import('jszip')).default;
757
769
  const zip = new JSZip();
@@ -876,6 +888,27 @@ export const archiveCase = async (
876
888
  compressionOptions: { level: 6 },
877
889
  });
878
890
 
891
+ await updateCaseData(user, caseNumber, archiveData);
892
+
893
+ // Clean up confirmation status metadata for this archived case
894
+ try {
895
+ await removeCaseConfirmationSummary(user, caseNumber);
896
+ } catch (summaryError) {
897
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
898
+ }
899
+
900
+ await auditService.logCaseArchive(
901
+ user,
902
+ caseNumber,
903
+ caseNumber,
904
+ archiveReason?.trim() || 'No reason provided',
905
+ 'success',
906
+ [],
907
+ archiveData.files?.length || 0,
908
+ archivedAt,
909
+ Date.now() - startTime
910
+ );
911
+
879
912
  const downloadUrl = URL.createObjectURL(zipBlob);
880
913
  const archiveFileName = `striae-case-${caseNumber}-archive-${formatDateForFilename(new Date())}.zip`;
881
914
  const anchor = document.createElement('a');
@@ -15,8 +15,11 @@ export const AuditEntriesList = ({ entries }: AuditEntriesListProps) => {
15
15
  <p>No activities match the current filters.</p>
16
16
  </div>
17
17
  ) : (
18
- entries.map((entry, index) => (
19
- <div key={index} className={`${styles.entry} ${styles[entry.result]}`}>
18
+ entries.map((entry) => (
19
+ <div
20
+ key={`${entry.timestamp}-${entry.userId}-${entry.action}-${entry.details.fileName || ''}`}
21
+ className={`${styles.entry} ${styles[entry.result]}`}
22
+ >
20
23
  <div className={styles.entryHeader}>
21
24
  <div className={styles.entryIcons}>
22
25
  <span className={styles.actionIcon}>{getAuditActionIcon(entry.action)}</span>
@@ -112,10 +112,13 @@ export const useAuditViewerData = ({
112
112
 
113
113
  if (effectiveCaseNumber) {
114
114
  const caseData = await getCaseData(user, effectiveCaseNumber);
115
- const archivedReadOnlyCase = Boolean(caseData?.isReadOnly && caseData.archived === true);
116
- setIsArchivedReadOnlyCase(archivedReadOnlyCase);
115
+ const isArchiveBundleCase = Boolean(
116
+ caseData?.archived === true &&
117
+ caseData?.bundledAuditTrail?.source === 'archive-bundle'
118
+ );
119
+ setIsArchivedReadOnlyCase(isArchiveBundleCase);
117
120
 
118
- if (archivedReadOnlyCase && !caseData?.bundledAuditTrail?.entries?.length) {
121
+ if (isArchiveBundleCase && !Array.isArray(caseData?.bundledAuditTrail?.entries)) {
119
122
  setBundledAuditWarning(
120
123
  'This imported archived case does not include bundled audit trail data. No audit entries are available for this case.'
121
124
  );
@@ -48,7 +48,7 @@ export const useAuditViewerExport = ({
48
48
  const filename = auditExportService.generateFilename(
49
49
  exportContextData.scopeType,
50
50
  exportContextData.identifier,
51
- 'csv'
51
+ 'json'
52
52
  );
53
53
 
54
54
  try {
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useContext } from 'react';
1
+ import { useState, useEffect, useContext, useRef } from 'react';
2
2
  import { type ConfirmationData } from '~/types/annotations';
3
3
  import { AuthContext } from '~/contexts/auth.context';
4
4
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
@@ -33,6 +33,7 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
33
33
  const [badgeId, setBadgeId] = useState('');
34
34
  const [error, setError] = useState('');
35
35
  const [isConfirming, setIsConfirming] = useState(false);
36
+ const wasOpenRef = useRef(false);
36
37
 
37
38
  const fullName = user?.displayName || user?.email || 'Unknown User';
38
39
  const userEmail = user?.email || 'No email available';
@@ -54,7 +55,10 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
54
55
 
55
56
  // Reset form when modal opens
56
57
  useEffect(() => {
57
- if (isOpen) {
58
+ const justOpened = isOpen && !wasOpenRef.current;
59
+ wasOpenRef.current = isOpen;
60
+
61
+ if (justOpened) {
58
62
  if (existingConfirmation) {
59
63
  setBadgeId(existingConfirmation.badgeId);
60
64
  } else {
@@ -2,6 +2,7 @@
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  gap: 0.75rem;
5
+ width: fit-content;
5
6
  }
6
7
 
7
8
  .colorHeader {
@@ -26,7 +27,7 @@
26
27
  }
27
28
 
28
29
  .colorWheel {
29
- width: 100%;
30
+ width: 180px;
30
31
  height: 40px;
31
32
  padding: 0;
32
33
  border: 2px solid #ced4da;
@@ -55,5 +56,5 @@
55
56
 
56
57
  .colorSwatch.selected {
57
58
  border-color: #0d6efd;
58
- box-shadow: 0 0 0 2px rgba(13,110,253,.25);
59
- }
59
+ box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
60
+ }
@@ -114,6 +114,7 @@ export const Navbar = ({
114
114
  }, [isCaseMenuOpen, isFileMenuOpen]);
115
115
 
116
116
  const caseActionsDisabled = false;
117
+ const disableLongRunningCaseActions = isUploading;
117
118
  const isCaseManagementActive = true;
118
119
  const isFileManagementActive = isFileMenuOpen || hasLoadedImage;
119
120
  const canOpenImageNotes = hasLoadedImage && !isCurrentImageConfirmed;
@@ -139,7 +140,7 @@ export const Navbar = ({
139
140
  aria-haspopup="menu"
140
141
  disabled={caseActionsDisabled}
141
142
  onClick={() => setIsCaseMenuOpen((prev) => !prev)}
142
- title={isUploading ? 'Cannot access case actions while uploading' : undefined}
143
+ title={isUploading ? 'Some case actions are unavailable while files are uploading' : undefined}
143
144
  >
144
145
  Case Management
145
146
  </button>
@@ -173,8 +174,14 @@ export const Navbar = ({
173
174
  type="button"
174
175
  role="menuitem"
175
176
  className={`${styles.caseMenuItem} ${styles.caseMenuItemExport}`}
176
- disabled={!hasLoadedCase}
177
- title={!hasLoadedCase ? 'Load a case to export case data' : undefined}
177
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
178
+ title={
179
+ !hasLoadedCase
180
+ ? 'Load a case to export case data'
181
+ : disableLongRunningCaseActions
182
+ ? 'Export is unavailable while files are uploading'
183
+ : undefined
184
+ }
178
185
  onClick={() => {
179
186
  onOpenCaseExport?.();
180
187
  setIsCaseMenuOpen(false);
@@ -203,8 +210,14 @@ export const Navbar = ({
203
210
  type="button"
204
211
  role="menuitem"
205
212
  className={`${styles.caseMenuItem} ${styles.caseMenuItemRename}`}
206
- disabled={!hasLoadedCase}
207
- title={!hasLoadedCase ? 'Load a case to rename it' : undefined}
213
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
214
+ title={
215
+ !hasLoadedCase
216
+ ? 'Load a case to rename it'
217
+ : disableLongRunningCaseActions
218
+ ? 'Rename is unavailable while files are uploading'
219
+ : undefined
220
+ }
208
221
  onClick={() => {
209
222
  onOpenRenameCase?.();
210
223
  setIsCaseMenuOpen(false);
@@ -218,8 +231,14 @@ export const Navbar = ({
218
231
  type="button"
219
232
  role="menuitem"
220
233
  className={`${styles.caseMenuItem} ${styles.caseMenuItemDelete}`}
221
- disabled={!hasLoadedCase}
222
- title={!hasLoadedCase ? 'Load a case to delete it' : undefined}
234
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
235
+ title={
236
+ !hasLoadedCase
237
+ ? 'Load a case to delete it'
238
+ : disableLongRunningCaseActions
239
+ ? 'Delete is unavailable while files are uploading'
240
+ : undefined
241
+ }
223
242
  onClick={() => {
224
243
  onDeleteCase?.();
225
244
  setIsCaseMenuOpen(false);
@@ -233,8 +252,14 @@ export const Navbar = ({
233
252
  type="button"
234
253
  role="menuitem"
235
254
  className={`${styles.caseMenuItem} ${styles.caseMenuItemArchive}`}
236
- disabled={!hasLoadedCase}
237
- title={!hasLoadedCase ? 'Load a case to archive it' : undefined}
255
+ disabled={!hasLoadedCase || disableLongRunningCaseActions}
256
+ title={
257
+ !hasLoadedCase
258
+ ? 'Load a case to archive it'
259
+ : disableLongRunningCaseActions
260
+ ? 'Archive is unavailable while files are uploading'
261
+ : undefined
262
+ }
238
263
  onClick={() => {
239
264
  onArchiveCase?.();
240
265
  setIsCaseMenuOpen(false);
@@ -9,13 +9,15 @@ import {
9
9
  } from '../../actions/image-manage';
10
10
  import {
11
11
  canUploadFile,
12
- getFileAnnotations
12
+ ensureCaseConfirmationSummary,
13
+ getCaseConfirmationSummary
13
14
  } from '~/utils/data';
14
15
  import { type FileData } from '~/types';
15
16
 
16
17
  interface CaseSidebarProps {
17
18
  user: User;
18
19
  onImageSelect: (file: FileData) => void;
20
+ onOpenCase: () => void;
19
21
  imageLoaded: boolean;
20
22
  setImageLoaded: (loaded: boolean) => void;
21
23
  onNotesClick: () => void;
@@ -34,6 +36,7 @@ interface CaseSidebarProps {
34
36
  export const CaseSidebar = ({
35
37
  user,
36
38
  onImageSelect,
39
+ onOpenCase,
37
40
  imageLoaded,
38
41
  setImageLoaded,
39
42
  onNotesClick,
@@ -67,21 +70,6 @@ export const CaseSidebar = ({
67
70
  [files]
68
71
  );
69
72
 
70
- const calculateCaseConfirmationStatus = useCallback((
71
- statuses: { [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean } }
72
- ) => {
73
- const filesRequiringConfirmation = files
74
- .map((file) => statuses[file.id] || { includeConfirmation: false, isConfirmed: false })
75
- .filter((status) => status.includeConfirmation);
76
-
77
- const allConfirmedFiles = filesRequiringConfirmation.every((status) => status.isConfirmed);
78
-
79
- return {
80
- includeConfirmation: filesRequiringConfirmation.length > 0,
81
- isConfirmed: filesRequiringConfirmation.length > 0 ? allConfirmedFiles : false,
82
- };
83
- }, [files]);
84
-
85
73
  // Function to check file upload permissions (extracted for reuse)
86
74
  const checkFileUploadPermissions = useCallback(async (fileCount?: number) => {
87
75
  if (currentCase) {
@@ -135,43 +123,24 @@ export const CaseSidebar = ({
135
123
  return;
136
124
  }
137
125
 
138
- // Fetch all annotations in parallel
139
- const annotationPromises = files.map(async (file) => {
140
- try {
141
- const annotations = await getFileAnnotations(user, currentCase, file.id);
142
- return {
143
- fileId: file.id,
144
- includeConfirmation: annotations?.includeConfirmation ?? false,
145
- isConfirmed: !!annotations?.confirmationData,
146
- };
147
- } catch (err) {
148
- console.error(`Error fetching annotations for file ${file.id}:`, err);
149
- return {
150
- fileId: file.id,
151
- includeConfirmation: false,
152
- isConfirmed: false,
153
- };
154
- }
126
+ const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files).catch((error) => {
127
+ console.error(`Error fetching confirmation summary for case ${currentCase}:`, error);
128
+ return null;
155
129
  });
156
130
 
157
- // Wait for all fetches to complete
158
- const results = await Promise.all(annotationPromises);
159
-
160
- // Build the statuses map from results
161
- const statuses: { [fileId: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
162
- results.forEach((result) => {
163
- statuses[result.fileId] = {
164
- includeConfirmation: result.includeConfirmation,
165
- isConfirmed: result.isConfirmed,
166
- };
167
- });
131
+ if (!caseSummary) {
132
+ return;
133
+ }
168
134
 
169
135
  if (isCancelled) {
170
136
  return;
171
137
  }
172
138
 
173
- setFileConfirmationStatus(statuses);
174
- setCaseConfirmationStatus(calculateCaseConfirmationStatus(statuses));
139
+ setFileConfirmationStatus(caseSummary.filesById);
140
+ setCaseConfirmationStatus({
141
+ includeConfirmation: caseSummary.includeConfirmation,
142
+ isConfirmed: caseSummary.isConfirmed
143
+ });
175
144
  };
176
145
 
177
146
  fetchConfirmationStatuses();
@@ -179,7 +148,7 @@ export const CaseSidebar = ({
179
148
  return () => {
180
149
  isCancelled = true;
181
150
  };
182
- }, [currentCase, fileIdsKey, user, files, calculateCaseConfirmationStatus]);
151
+ }, [currentCase, fileIdsKey, user, files]);
183
152
 
184
153
  // Refresh only selected file confirmation status after confirmation-related data is persisted
185
154
  useEffect(() => {
@@ -191,24 +160,18 @@ export const CaseSidebar = ({
191
160
  }
192
161
 
193
162
  try {
194
- const annotations = await getFileAnnotations(user, currentCase, selectedFileId);
195
- const selectedStatus = {
196
- includeConfirmation: annotations?.includeConfirmation ?? false,
197
- isConfirmed: !!annotations?.confirmationData,
198
- };
163
+ const caseSummary =
164
+ await getCaseConfirmationSummary(user, currentCase) ||
165
+ await ensureCaseConfirmationSummary(user, currentCase, files);
199
166
 
200
167
  if (isCancelled) {
201
168
  return;
202
169
  }
203
170
 
204
- setFileConfirmationStatus((previous) => {
205
- const next = {
206
- ...previous,
207
- [selectedFileId]: selectedStatus,
208
- };
209
-
210
- setCaseConfirmationStatus(calculateCaseConfirmationStatus(next));
211
- return next;
171
+ setFileConfirmationStatus(caseSummary.filesById);
172
+ setCaseConfirmationStatus({
173
+ includeConfirmation: caseSummary.includeConfirmation,
174
+ isConfirmed: caseSummary.isConfirmed
212
175
  });
213
176
  } catch (err) {
214
177
  console.error(`Error refreshing confirmation status for file ${selectedFileId}:`, err);
@@ -220,7 +183,7 @@ export const CaseSidebar = ({
220
183
  return () => {
221
184
  isCancelled = true;
222
185
  };
223
- }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, files.length, calculateCaseConfirmationStatus]);
186
+ }, [currentCase, fileIdsKey, user, selectedFileId, confirmationSaveVersion, files]);
224
187
 
225
188
  const handleFileDelete = async (fileId: string) => {
226
189
  // Don't allow file deletion for read-only cases
@@ -303,19 +266,30 @@ return (
303
266
  setFiles={setFiles}
304
267
  isReadOnly={isReadOnly}
305
268
  selectedFileId={selectedFileId}
269
+ confirmationSaveVersion={confirmationSaveVersion}
306
270
  />
307
271
 
308
272
  <div className={styles.filesSection}>
309
273
  <div className={currentCase ? (isReadOnly ? styles.readOnlyContainer : styles.caseHeader) : styles.emptyCaseHeader}>
310
- <h4 className={`${styles.caseNumber} ${
311
- currentCase && caseConfirmationStatus.includeConfirmation
312
- ? caseConfirmationStatus.isConfirmed
313
- ? styles.caseConfirmed
314
- : styles.caseNotConfirmed
315
- : ''
316
- }`}>
317
- {currentCase || 'No Case Selected'}
318
- </h4>
274
+ {currentCase ? (
275
+ <h4 className={`${styles.caseNumber} ${
276
+ caseConfirmationStatus.includeConfirmation
277
+ ? caseConfirmationStatus.isConfirmed
278
+ ? styles.caseConfirmed
279
+ : styles.caseNotConfirmed
280
+ : ''
281
+ }`}>
282
+ {currentCase}
283
+ </h4>
284
+ ) : (
285
+ <button
286
+ type="button"
287
+ className={styles.openCaseButton}
288
+ onClick={onOpenCase}
289
+ >
290
+ Open Case
291
+ </button>
292
+ )}
319
293
  </div>
320
294
  {currentCase && (
321
295
  <ImageUploadZone
@@ -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,9 +12,17 @@ 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>('');
@@ -70,6 +78,43 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
70
78
 
71
79
  // Fetch confirmation status only for currently visible paginated cases
72
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
+
73
118
  const fetchCaseConfirmationStatuses = async () => {
74
119
  const visibleCases = cases.slice(
75
120
  currentPage * CASES_PER_PAGE,
@@ -80,34 +125,21 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
80
125
  return;
81
126
  }
82
127
 
83
- // Fetch case statuses in parallel for only visible cases
84
- 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) => {
85
134
  try {
86
135
  const files = await fetchFiles(user, caseNum, { skipValidation: true });
87
-
88
- // Fetch annotations for each file in the case (in parallel)
89
- const fileStatuses = await Promise.all(
90
- files.map(async (file) => {
91
- try {
92
- const annotations = await getFileAnnotations(user, caseNum, file.id);
93
- return {
94
- includeConfirmation: annotations?.includeConfirmation ?? false,
95
- isConfirmed: !!(annotations?.includeConfirmation && annotations?.confirmationData),
96
- };
97
- } catch {
98
- return { includeConfirmation: false, isConfirmed: false };
99
- }
100
- })
101
- );
102
-
103
- // Calculate case status
104
- const filesRequiringConfirmation = fileStatuses.filter(s => s.includeConfirmation);
105
- const allConfirmedFiles = filesRequiringConfirmation.every(s => s.isConfirmed);
136
+
137
+ const caseSummary = await ensureCaseConfirmationSummary(user, caseNum, files);
106
138
 
107
139
  return {
108
140
  caseNum,
109
- includeConfirmation: filesRequiringConfirmation.length > 0,
110
- isConfirmed: filesRequiringConfirmation.length > 0 ? allConfirmedFiles : false,
141
+ includeConfirmation: caseSummary.includeConfirmation,
142
+ isConfirmed: caseSummary.isConfirmed,
111
143
  };
112
144
  } catch (err) {
113
145
  console.error(`Error fetching confirmation status for case ${caseNum}:`, err);
@@ -122,20 +154,29 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
122
154
  // Wait for all case status fetches to complete
123
155
  const results = await Promise.all(caseStatusPromises);
124
156
 
125
- // Build the statuses map from results
126
- const statuses: { [caseNum: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
127
- results.forEach((result) => {
128
- statuses[result.caseNum] = {
129
- includeConfirmation: result.includeConfirmation,
130
- isConfirmed: result.isConfirmed,
131
- };
132
- });
157
+ if (isCancelled) {
158
+ return;
159
+ }
133
160
 
134
- 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
+ });
135
172
  };
136
173
 
137
174
  fetchCaseConfirmationStatuses();
138
- }, [isOpen, currentPage, cases, user]);
175
+
176
+ return () => {
177
+ isCancelled = true;
178
+ };
179
+ }, [isOpen, currentPage, cases, user, caseConfirmationStatus]);
139
180
 
140
181
  if (!isOpen) return null;
141
182
 
@@ -204,6 +204,24 @@
204
204
  margin-top: 0.75rem;
205
205
  }
206
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
+
207
225
  .fileListPlaceholder {
208
226
  display: flex;
209
227
  align-items: center;
@@ -678,6 +696,7 @@
678
696
  /* Case Header Container */
679
697
  .caseHeader {
680
698
  /* Normal case header styling (no background) */
699
+ margin-top: 0.75rem;
681
700
  }
682
701
 
683
702
  .readOnlyContainer {
@@ -685,6 +704,7 @@
685
704
  border: 1px solid #ffeaa7;
686
705
  border-radius: 4px;
687
706
  padding: 0.625rem 0.75rem;
707
+ margin-top: 0.75rem;
688
708
  margin-bottom: 0;
689
709
  }
690
710