@striae-org/striae 6.1.3 → 6.1.5

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 (33) hide show
  1. package/app/components/actions/case-manage/operations.ts +17 -0
  2. package/app/components/auth/mfa-enrollment.tsx +10 -8
  3. package/app/components/auth/mfa-verification.tsx +10 -10
  4. package/app/components/navbar/case-modals/all-cases-modal.tsx +93 -109
  5. package/app/components/sidebar/cases/case-sidebar.tsx +40 -22
  6. package/app/components/sidebar/files/files-modal.tsx +5 -0
  7. package/app/components/sidebar/sidebar-container.tsx +2 -0
  8. package/app/components/sidebar/sidebar.tsx +4 -0
  9. package/app/components/user/mfa-phone-update.tsx +11 -9
  10. package/app/components/user/mfa-totp-section.tsx +8 -7
  11. package/app/root.tsx +1 -0
  12. package/app/routes/auth/{login.example.tsx → login.tsx} +18 -18
  13. package/app/routes/striae/striae.tsx +82 -46
  14. package/app/utils/data/confirmation-summary/summary-core.ts +6 -0
  15. package/app/utils/data/operations/confirmation-summary-operations.ts +3 -1
  16. package/app/utils/data/permissions.ts +29 -4
  17. package/package.json +138 -140
  18. package/public/_headers +11 -1
  19. package/public/robots.txt +2 -0
  20. package/scripts/deploy-config/modules/scaffolding.sh +0 -17
  21. package/worker-configuration.d.ts +2 -2
  22. package/workers/audit-worker/package.json +1 -1
  23. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  24. package/workers/data-worker/package.json +1 -1
  25. package/workers/data-worker/wrangler.jsonc.example +1 -1
  26. package/workers/image-worker/package.json +1 -1
  27. package/workers/image-worker/wrangler.jsonc.example +1 -1
  28. package/workers/pdf-worker/package.json +1 -1
  29. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  30. package/workers/user-worker/package.json +1 -1
  31. package/workers/user-worker/wrangler.jsonc.example +1 -1
  32. package/wrangler.toml.example +1 -1
  33. /package/app/routes/auth/{login.module.example.css → login.module.css} +0 -0
@@ -29,6 +29,23 @@ import { type CaseArchiveDetails, type DeleteCaseResult } from './types';
29
29
  export type { DeleteCaseResult, CaseArchiveDetails };
30
30
  export { validateCaseNumber };
31
31
 
32
+ /**
33
+ * Derive archive details from already-fetched case data without making an additional
34
+ * network request. Use this when CaseData is already available to avoid a redundant fetch.
35
+ */
36
+ export const deriveCaseArchiveDetails = (caseData: CaseData | null): CaseArchiveDetails => {
37
+ if (!caseData || !caseData.archived) {
38
+ return { archived: false };
39
+ }
40
+ return {
41
+ archived: true,
42
+ archivedAt: caseData.archivedAt,
43
+ archivedBy: caseData.archivedBy,
44
+ archivedByDisplay: caseData.archivedByDisplay,
45
+ archiveReason: caseData.archiveReason,
46
+ };
47
+ };
48
+
32
49
  export const listCases = async (user: User): Promise<string[]> => {
33
50
  try {
34
51
  // Use centralized function to get user cases
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable react/prop-types */
2
- import { useState, useEffect } from 'react';
2
+ import { useState, useEffect, useRef } from 'react';
3
3
  import { auth } from '~/services/firebase';
4
4
  import {
5
5
  PhoneAuthProvider,
@@ -34,7 +34,7 @@ export const MFAEnrollment: React.FC<MFAEnrollmentProps> = ({
34
34
  const [verificationCode, setVerificationCode] = useState('');
35
35
  const [isLoading, setIsLoading] = useState(false);
36
36
  const [codeSent, setCodeSent] = useState(false);
37
- const [recaptchaVerifier, setRecaptchaVerifier] = useState<RecaptchaVerifier | null>(null);
37
+ const recaptchaVerifierRef = useRef<RecaptchaVerifier | null>(null);
38
38
  const [verificationId, setVerificationId] = useState('');
39
39
  const [resendTimer, setResendTimer] = useState(0);
40
40
  const [isClient, setIsClient] = useState(false);
@@ -46,8 +46,8 @@ export const MFAEnrollment: React.FC<MFAEnrollmentProps> = ({
46
46
 
47
47
  useEffect(() => {
48
48
  if (!isClient) return;
49
-
50
- // Initialize reCAPTCHA verifier
49
+
50
+ // Initialize reCAPTCHA verifier only after the container element is in the DOM
51
51
  const verifier = new RecaptchaVerifier(auth, 'recaptcha-container-enrollment', {
52
52
  size: 'invisible',
53
53
  callback: () => {
@@ -59,12 +59,13 @@ export const MFAEnrollment: React.FC<MFAEnrollmentProps> = ({
59
59
  onError(error);
60
60
  }
61
61
  });
62
- setRecaptchaVerifier(verifier);
62
+ recaptchaVerifierRef.current = verifier;
63
63
 
64
64
  return () => {
65
65
  verifier.clear();
66
+ recaptchaVerifierRef.current = null;
66
67
  };
67
- }, [onError, isClient]);
68
+ }, [isClient, onError]);
68
69
 
69
70
  useEffect(() => {
70
71
  if (resendTimer > 0) {
@@ -129,7 +130,8 @@ export const MFAEnrollment: React.FC<MFAEnrollmentProps> = ({
129
130
  return;
130
131
  }
131
132
 
132
- if (!recaptchaVerifier) {
133
+ const captchaVerifier = recaptchaVerifierRef.current;
134
+ if (!captchaVerifier) {
133
135
  const error = getValidationError('MFA_RECAPTCHA_ERROR');
134
136
  setErrorMessage(error);
135
137
  onError(error);
@@ -151,7 +153,7 @@ export const MFAEnrollment: React.FC<MFAEnrollmentProps> = ({
151
153
  const phoneAuthProvider = new PhoneAuthProvider(auth);
152
154
  const verificationId = await phoneAuthProvider.verifyPhoneNumber(
153
155
  phoneInfoOptions,
154
- recaptchaVerifier
156
+ captchaVerifier
155
157
  );
156
158
 
157
159
  setVerificationId(verificationId);
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useState, useEffect, useRef } from 'react';
2
2
  import {
3
3
  PhoneAuthProvider,
4
4
  PhoneMultiFactorGenerator,
@@ -36,7 +36,7 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
36
36
  const [verificationId, setVerificationId] = useState('');
37
37
  const [loading, setLoading] = useState(false);
38
38
  const [codeSent, setCodeSent] = useState(false);
39
- const [recaptchaVerifier, setRecaptchaVerifier] = useState<RecaptchaVerifier | null>(null);
39
+ const recaptchaVerifierRef = useRef<RecaptchaVerifier | null>(null);
40
40
  const [isClient, setIsClient] = useState(false);
41
41
  const [errorMessage, setErrorMessage] = useState('');
42
42
 
@@ -52,8 +52,8 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
52
52
  (h) => h.factorId === PhoneMultiFactorGenerator.FACTOR_ID
53
53
  );
54
54
  if (!hasPhoneHint) return;
55
-
56
- // Initialize reCAPTCHA verifier
55
+
56
+ // Initialize reCAPTCHA verifier only after the container element is in the DOM
57
57
  const verifier = new RecaptchaVerifier(auth, 'recaptcha-container', {
58
58
  size: 'invisible',
59
59
  callback: () => {
@@ -65,15 +65,17 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
65
65
  onError(error);
66
66
  }
67
67
  });
68
- setRecaptchaVerifier(verifier);
68
+ recaptchaVerifierRef.current = verifier;
69
69
 
70
70
  return () => {
71
71
  verifier.clear();
72
+ recaptchaVerifierRef.current = null;
72
73
  };
73
74
  }, [isClient, onError, resolver.hints]);
74
75
 
75
76
  const sendVerificationCode = async () => {
76
- if (!recaptchaVerifier) {
77
+ const captchaVerifier = recaptchaVerifierRef.current;
78
+ if (!captchaVerifier) {
77
79
  const error = getValidationError('MFA_RECAPTCHA_ERROR');
78
80
  setErrorMessage(error);
79
81
  onError(error);
@@ -90,7 +92,7 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
90
92
  session: resolver.session
91
93
  };
92
94
 
93
- const vId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier);
95
+ const vId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, captchaVerifier);
94
96
  setVerificationId(vId);
95
97
  setCodeSent(true);
96
98
  } catch (error: unknown) {
@@ -102,9 +104,7 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
102
104
  }
103
105
  setErrorMessage(errorMsg);
104
106
  onError(errorMsg);
105
- if (recaptchaVerifier) {
106
- recaptchaVerifier.clear();
107
- }
107
+ recaptchaVerifierRef.current?.clear();
108
108
  } finally {
109
109
  setLoading(false);
110
110
  }
@@ -24,6 +24,7 @@ import {
24
24
  getConfirmationSummaryDocument,
25
25
  getUserCases,
26
26
  getUserReadOnlyCases,
27
+ type UserConfirmationSummaryDocument,
27
28
  } from '~/utils/data';
28
29
  import { fetchFiles } from '~/components/actions/image-manage';
29
30
  import styles from './all-cases-modal.module.css';
@@ -35,6 +36,7 @@ interface CasesModalProps {
35
36
  currentCase: string;
36
37
  user: User;
37
38
  confirmationSaveVersion?: number;
39
+ initialConfirmationSummary?: UserConfirmationSummaryDocument;
38
40
  }
39
41
 
40
42
  interface CaseConfirmationStatus {
@@ -64,7 +66,8 @@ export const CasesModal = ({
64
66
  onSelectCase,
65
67
  currentCase,
66
68
  user,
67
- confirmationSaveVersion = 0
69
+ confirmationSaveVersion = 0,
70
+ initialConfirmationSummary,
68
71
  }: CasesModalProps) => {
69
72
  const [allCases, setAllCases] = useState<CasesModalCaseItem[]>([]);
70
73
  const [isLoading, setIsLoading] = useState(false);
@@ -96,73 +99,74 @@ export const CasesModal = ({
96
99
  });
97
100
  const [caseConfirmationStatus, setCaseConfirmationStatus] = useState<CaseConfirmationStatus>({});
98
101
  const caseConfirmationStatusRef = useRef<CaseConfirmationStatus>({});
99
-
100
- const loadCases = useCallback(async () => {
101
- try {
102
- const [ownedCases, readOnlyCases] = await Promise.all([
103
- getUserCases(user),
104
- getUserReadOnlyCases(user),
105
- ]);
106
-
107
- const ownedCaseEntries = await Promise.all(
108
- ownedCases.map(async (entry) => {
109
- const caseData = await getCaseData(user, entry.caseNumber).catch(() => null);
110
-
111
- return {
112
- caseNumber: entry.caseNumber,
113
- createdAt: entry.createdAt,
114
- archived: caseData?.archived === true,
115
- isReadOnly: false,
116
- } as CasesModalCaseItem;
117
- })
118
- );
119
-
120
- const readOnlyEntries: CasesModalCaseItem[] = readOnlyCases.map((entry) => ({
121
- caseNumber: entry.caseNumber,
122
- createdAt: entry.importedAt,
123
- archived: false,
124
- isReadOnly: true,
125
- }));
126
-
127
- const mergedCasesMap = new Map<string, CasesModalCaseItem>();
128
- [...ownedCaseEntries, ...readOnlyEntries].forEach((entry) => {
129
- if (!mergedCasesMap.has(entry.caseNumber)) {
130
- mergedCasesMap.set(entry.caseNumber, entry);
131
- }
132
- });
133
-
134
- setAllCases(Array.from(mergedCasesMap.values()));
135
- setSelectedCaseNumber((previous) => previous ?? (currentCase || null));
136
- } catch (err) {
137
- console.error('Failed to load cases:', err);
138
- setError('Failed to load cases');
139
- } finally {
140
- setIsLoading(false);
141
- }
142
- }, [user, currentCase]);
102
+ const [refreshKey, setRefreshKey] = useState(0);
143
103
 
144
104
  useEffect(() => {
145
105
  caseConfirmationStatusRef.current = caseConfirmationStatus;
146
106
  }, [caseConfirmationStatus]);
147
107
 
148
- const startLoading = () => {
149
- setIsLoading(true);
150
- setError('');
151
- };
152
-
153
108
  useEffect(() => {
154
- if (isOpen) {
155
- const loadingTimer = window.setTimeout(() => {
156
- startLoading();
157
- }, 0);
109
+ if (!isOpen) return;
158
110
 
159
- void loadCases();
111
+ let isCancelled = false;
160
112
 
161
- return () => {
162
- window.clearTimeout(loadingTimer);
163
- };
164
- }
165
- }, [isOpen, loadCases]);
113
+ const load = async () => {
114
+ setIsLoading(true);
115
+ setError('');
116
+
117
+ try {
118
+ const [ownedCases, readOnlyCases] = await Promise.all([
119
+ getUserCases(user),
120
+ getUserReadOnlyCases(user),
121
+ ]);
122
+
123
+ const ownedCaseEntries = await Promise.all(
124
+ ownedCases.map(async (entry) => {
125
+ const caseData = await getCaseData(user, entry.caseNumber).catch(() => null);
126
+
127
+ return {
128
+ caseNumber: entry.caseNumber,
129
+ createdAt: entry.createdAt,
130
+ archived: caseData?.archived === true,
131
+ isReadOnly: false,
132
+ } as CasesModalCaseItem;
133
+ })
134
+ );
135
+
136
+ const readOnlyEntries: CasesModalCaseItem[] = readOnlyCases.map((entry) => ({
137
+ caseNumber: entry.caseNumber,
138
+ createdAt: entry.importedAt,
139
+ archived: false,
140
+ isReadOnly: true,
141
+ }));
142
+
143
+ const mergedCasesMap = new Map<string, CasesModalCaseItem>();
144
+ [...ownedCaseEntries, ...readOnlyEntries].forEach((entry) => {
145
+ if (!mergedCasesMap.has(entry.caseNumber)) {
146
+ mergedCasesMap.set(entry.caseNumber, entry);
147
+ }
148
+ });
149
+
150
+ if (!isCancelled) {
151
+ setAllCases(Array.from(mergedCasesMap.values()));
152
+ setSelectedCaseNumber((previous) => previous ?? (currentCase || null));
153
+ setIsLoading(false);
154
+ }
155
+ } catch (err) {
156
+ console.error('Failed to load cases:', err);
157
+ if (!isCancelled) {
158
+ setError('Failed to load cases');
159
+ setIsLoading(false);
160
+ }
161
+ }
162
+ };
163
+
164
+ void load();
165
+
166
+ return () => {
167
+ isCancelled = true;
168
+ };
169
+ }, [isOpen, user, currentCase, refreshKey]);
166
170
 
167
171
  const archiveScopedCases = useMemo(() => {
168
172
  if (preferences.showArchivedOnly) {
@@ -191,16 +195,11 @@ export const CasesModal = ({
191
195
  );
192
196
 
193
197
  const totalPages = Math.max(1, Math.ceil(visibleCases.length / CASES_PER_PAGE));
194
-
195
- useEffect(() => {
196
- if (currentPage > totalPages - 1) {
197
- setCurrentPage(totalPages - 1);
198
- }
199
- }, [currentPage, totalPages]);
198
+ const effectiveCurrentPage = Math.min(currentPage, totalPages - 1);
200
199
 
201
200
  const paginatedCases = visibleCases.slice(
202
- currentPage * CASES_PER_PAGE,
203
- (currentPage + 1) * CASES_PER_PAGE
201
+ effectiveCurrentPage * CASES_PER_PAGE,
202
+ (effectiveCurrentPage + 1) * CASES_PER_PAGE
204
203
  );
205
204
 
206
205
  const hasCustomPreferences =
@@ -213,6 +212,9 @@ export const CasesModal = ({
213
212
  [allCases, selectedCaseNumber]
214
213
  );
215
214
 
215
+ // Derived from the memo — naturally null when the selected case no longer exists in allCases.
216
+ const effectiveSelectedCaseNumber = selectedCase?.caseNumber ?? null;
217
+
216
218
  const canRenameSelectedCase = Boolean(
217
219
  selectedCase && !selectedCase.archived && !selectedCase.isReadOnly
218
220
  );
@@ -233,31 +235,7 @@ export const CasesModal = ({
233
235
  ? 'Read-only review cases cannot be deleted. Use Clear RO Case under Case Management first.'
234
236
  : undefined;
235
237
 
236
- useEffect(() => {
237
- setCurrentPage(0);
238
- }, [preferences.sortBy, preferences.confirmationFilter, preferences.showArchivedOnly]);
239
-
240
- useEffect(() => {
241
- if (paginatedCases.length === 0) {
242
- setFocusedIndex(0);
243
- return;
244
- }
245
-
246
- if (focusedIndex > paginatedCases.length - 1) {
247
- setFocusedIndex(paginatedCases.length - 1);
248
- }
249
- }, [paginatedCases, focusedIndex]);
250
-
251
- useEffect(() => {
252
- if (!selectedCaseNumber) {
253
- return;
254
- }
255
-
256
- const exists = allCases.some((entry) => entry.caseNumber === selectedCaseNumber);
257
- if (!exists) {
258
- setSelectedCaseNumber(null);
259
- }
260
- }, [allCases, selectedCaseNumber]);
238
+ const effectiveFocusedIndex = paginatedCases.length === 0 ? 0 : Math.min(focusedIndex, paginatedCases.length - 1);
261
239
 
262
240
  const hydrateCaseConfirmationStatuses = useCallback(async (caseNumbers: string[]) => {
263
241
  const missingCaseNumbers = caseNumbers.filter(
@@ -313,10 +291,16 @@ export const CasesModal = ({
313
291
  return;
314
292
  }
315
293
 
316
- const summary = await getConfirmationSummaryDocument(user).catch((err) => {
317
- console.error('Failed to load confirmation summary:', err);
318
- return null;
319
- });
294
+ // Use the pre-fetched summary if available and no confirmation saves have
295
+ // been made since case load (confirmationSaveVersion === 0). When saves
296
+ // have occurred the summary may be stale, so re-fetch from the data worker.
297
+ const summary =
298
+ initialConfirmationSummary && confirmationSaveVersion === 0
299
+ ? initialConfirmationSummary
300
+ : await getConfirmationSummaryDocument(user).catch((err) => {
301
+ console.error('Failed to load confirmation summary:', err);
302
+ return null;
303
+ });
320
304
 
321
305
  if (!summary || isCancelled) {
322
306
  return;
@@ -338,7 +322,7 @@ export const CasesModal = ({
338
322
  return () => {
339
323
  isCancelled = true;
340
324
  };
341
- }, [isOpen, user, confirmationSaveVersion]);
325
+ }, [isOpen, user, confirmationSaveVersion, initialConfirmationSummary]);
342
326
 
343
327
  useEffect(() => {
344
328
  if (!isOpen || paginatedCases.length === 0) {
@@ -367,11 +351,11 @@ export const CasesModal = ({
367
351
  };
368
352
 
369
353
  const handleOpenSelectedCase = () => {
370
- if (!selectedCaseNumber) {
354
+ if (!effectiveSelectedCaseNumber) {
371
355
  return;
372
356
  }
373
357
 
374
- onSelectCase(selectedCaseNumber);
358
+ onSelectCase(effectiveSelectedCaseNumber);
375
359
  requestClose();
376
360
  };
377
361
 
@@ -419,7 +403,6 @@ export const CasesModal = ({
419
403
 
420
404
  try {
421
405
  await renameCase(user, selectedCase.caseNumber, nextCaseNumber);
422
- await loadCases();
423
406
  setSelectedCaseNumber(nextCaseNumber);
424
407
  setIsRenameModalOpen(false);
425
408
 
@@ -427,6 +410,7 @@ export const CasesModal = ({
427
410
  onSelectCase(nextCaseNumber);
428
411
  }
429
412
 
413
+ setRefreshKey((k) => k + 1);
430
414
  setActionNotice({
431
415
  type: 'success',
432
416
  message: `Case renamed to ${nextCaseNumber}.`,
@@ -468,13 +452,13 @@ export const CasesModal = ({
468
452
 
469
453
  try {
470
454
  await archiveCase(user, selectedCase.caseNumber, archiveReason);
471
- await loadCases();
472
455
  setIsArchiveModalOpen(false);
473
456
 
474
457
  if (selectedCase.caseNumber === currentCase) {
475
458
  onSelectCase(selectedCase.caseNumber);
476
459
  }
477
460
 
461
+ setRefreshKey((k) => k + 1);
478
462
  setActionNotice({
479
463
  type: 'success',
480
464
  message: 'Case archived successfully.',
@@ -523,9 +507,9 @@ export const CasesModal = ({
523
507
 
524
508
  try {
525
509
  const deleteResult = await deleteCase(user, selectedCase.caseNumber);
526
- await loadCases();
527
510
  setSelectedCaseNumber(null);
528
511
  setIsDeleteModalOpen(false);
512
+ setRefreshKey((k) => k + 1);
529
513
 
530
514
  if (deleteResult.missingImages.length > 0) {
531
515
  setActionNotice({
@@ -693,7 +677,7 @@ export const CasesModal = ({
693
677
  {paginatedCases.map((caseEntry, index) => {
694
678
  const caseNum = caseEntry.caseNumber;
695
679
  const confirmationStatus = caseConfirmationStatus[caseNum] || DEFAULT_CONFIRMATION_STATUS;
696
- const isSelected = selectedCaseNumber === caseNum;
680
+ const isSelected = effectiveSelectedCaseNumber === caseNum;
697
681
  const confirmationLabel = confirmationStatus.includeConfirmation
698
682
  ? confirmationStatus.isConfirmed
699
683
  ? 'Confirmed'
@@ -716,7 +700,7 @@ export const CasesModal = ({
716
700
  }}
717
701
  role="option"
718
702
  aria-selected={isSelected}
719
- tabIndex={focusedIndex === index ? 0 : -1}
703
+ tabIndex={effectiveFocusedIndex === index ? 0 : -1}
720
704
  className={`${styles.caseItem} ${isSelected ? styles.active : ''}`}
721
705
  onClick={() => handleSelectCase(caseNum, index)}
722
706
  onFocus={() => setFocusedIndex(index)}
@@ -806,7 +790,7 @@ export const CasesModal = ({
806
790
  type="button"
807
791
  className={styles.openSelectedButton}
808
792
  onClick={handleOpenSelectedCase}
809
- disabled={!selectedCaseNumber || isRunningAction}
793
+ disabled={!effectiveSelectedCaseNumber || isRunningAction}
810
794
  >
811
795
  {isRunningAction ? 'Working...' : 'Open Selected Case'}
812
796
  </button>
@@ -815,14 +799,14 @@ export const CasesModal = ({
815
799
  <div className={styles.pagination}>
816
800
  <button
817
801
  onClick={() => setCurrentPage(p => p - 1)}
818
- disabled={currentPage === 0}
802
+ disabled={effectiveCurrentPage === 0}
819
803
  >
820
804
  Previous
821
805
  </button>
822
- <span>{currentPage + 1} of {totalPages} ({visibleCases.length} filtered cases)</span>
806
+ <span>{effectiveCurrentPage + 1} of {totalPages} ({visibleCases.length} filtered cases)</span>
823
807
  <button
824
808
  onClick={() => setCurrentPage(p => p + 1)}
825
- disabled={currentPage === totalPages - 1}
809
+ disabled={effectiveCurrentPage === totalPages - 1}
826
810
  >
827
811
  Next
828
812
  </button>
@@ -5,7 +5,6 @@ import styles from './cases.module.css';
5
5
  import { FilesModal } from '../files/files-modal';
6
6
  import { ImageUploadZone } from '../upload/image-upload-zone';
7
7
  import {
8
- fetchFiles,
9
8
  deleteFile,
10
9
  } from '../../actions/image-manage';
11
10
  import {
@@ -13,7 +12,8 @@ import {
13
12
  ensureCaseConfirmationSummary,
14
13
  getCaseConfirmationSummary,
15
14
  getNotesViewPermission,
16
- getNotesButtonTooltip
15
+ getNotesButtonTooltip,
16
+ type UserConfirmationSummaryDocument
17
17
  } from '~/utils/data';
18
18
  import { type FileData } from '~/types';
19
19
 
@@ -36,6 +36,7 @@ interface CaseSidebarProps {
36
36
  onUploadStatusChange?: (isUploading: boolean) => void;
37
37
  onUploadComplete?: (result: { successCount: number; failedFiles: string[] }) => void;
38
38
  onOpenCaseExport?: () => void;
39
+ initialConfirmationSummary?: UserConfirmationSummaryDocument;
39
40
  }
40
41
 
41
42
  export const CaseSidebar = ({
@@ -56,7 +57,8 @@ export const CaseSidebar = ({
56
57
  isUploading = false,
57
58
  onUploadStatusChange,
58
59
  onUploadComplete,
59
- onOpenCaseExport
60
+ onOpenCaseExport,
61
+ initialConfirmationSummary,
60
62
  }: CaseSidebarProps) => {
61
63
 
62
64
  const [, setFileError] = useState('');
@@ -97,25 +99,35 @@ export const CaseSidebar = ({
97
99
  }
98
100
  }, [currentCase, files.length, user]);
99
101
 
100
- // Check file upload permissions when currentCase or files change
102
+ // Check file upload permissions when currentCase or files change.
101
103
  useEffect(() => {
102
- checkFileUploadPermissions();
103
- }, [checkFileUploadPermissions]);
104
+ let isCancelled = false;
105
+ const check = async () => {
106
+ if (currentCase) {
107
+ try {
108
+ const permission = await canUploadFile(user, files.length);
109
+ if (!isCancelled) {
110
+ setCanUploadNewFile(permission.canUpload);
111
+ setUploadFileError(permission.reason || '');
112
+ }
113
+ } catch (error) {
114
+ console.error('Error checking file upload permission:', error);
115
+ if (!isCancelled) {
116
+ setCanUploadNewFile(false);
117
+ setUploadFileError('Unable to verify upload permissions');
118
+ }
119
+ }
120
+ } else if (!isCancelled) {
121
+ setCanUploadNewFile(true);
122
+ setUploadFileError('');
123
+ }
124
+ };
125
+ void check();
126
+ return () => {
127
+ isCancelled = true;
128
+ };
129
+ }, [currentCase, files.length, user]);
104
130
 
105
- useEffect(() => {
106
- if (currentCase) {
107
- fetchFiles(user, currentCase, { skipValidation: true })
108
- .then(loadedFiles => {
109
- setFiles(loadedFiles);
110
- })
111
- .catch(err => {
112
- console.error('Failed to load files:', err);
113
- setFileError(err instanceof Error ? err.message : 'Failed to load files');
114
- });
115
- } else {
116
- setFiles([]);
117
- }
118
- }, [user, currentCase, setFiles]);
119
131
 
120
132
  // Fetch confirmation status for all files when case/files change
121
133
  useEffect(() => {
@@ -130,7 +142,12 @@ export const CaseSidebar = ({
130
142
  return;
131
143
  }
132
144
 
133
- const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files).catch((error) => {
145
+ const caseSummary = await ensureCaseConfirmationSummary(
146
+ user,
147
+ currentCase,
148
+ files,
149
+ confirmationSaveVersion === 0 ? { prefetchedSummary: initialConfirmationSummary } : undefined
150
+ ).catch((error) => {
134
151
  console.error(`Error fetching confirmation summary for case ${currentCase}:`, error);
135
152
  return null;
136
153
  });
@@ -155,7 +172,7 @@ export const CaseSidebar = ({
155
172
  return () => {
156
173
  isCancelled = true;
157
174
  };
158
- }, [currentCase, fileIdsKey, user, files]);
175
+ }, [currentCase, fileIdsKey, user, files, initialConfirmationSummary, confirmationSaveVersion]);
159
176
 
160
177
  // Refresh only selected file confirmation status after confirmation-related data is persisted
161
178
  useEffect(() => {
@@ -283,6 +300,7 @@ return (
283
300
  isReadOnly={isReadOnly}
284
301
  selectedFileId={selectedFileId}
285
302
  confirmationSaveVersion={confirmationSaveVersion}
303
+ initialConfirmationSummary={initialConfirmationSummary}
286
304
  />
287
305
 
288
306
  <div className={styles.filesSection}>
@@ -16,6 +16,7 @@ import { deleteFile } from '~/components/actions/image-manage';
16
16
  import {
17
17
  ensureCaseConfirmationSummary,
18
18
  type FileConfirmationSummary,
19
+ type UserConfirmationSummaryDocument,
19
20
  } from '~/utils/data';
20
21
  import { type FileData } from '~/types';
21
22
  import { DeleteFilesModal } from './delete-files-modal';
@@ -31,6 +32,7 @@ interface FilesModalProps {
31
32
  isReadOnly?: boolean;
32
33
  selectedFileId?: string;
33
34
  confirmationSaveVersion?: number;
35
+ initialConfirmationSummary?: UserConfirmationSummaryDocument;
34
36
  }
35
37
 
36
38
  interface ActionNotice {
@@ -99,6 +101,7 @@ export const FilesModal = ({
99
101
  isReadOnly = false,
100
102
  selectedFileId,
101
103
  confirmationSaveVersion = 0,
104
+ initialConfirmationSummary,
102
105
  }: FilesModalProps) => {
103
106
  const { user } = useContext(AuthContext);
104
107
  const [currentPage, setCurrentPage] = useState(0);
@@ -190,6 +193,7 @@ export const FilesModal = ({
190
193
 
191
194
  const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files, {
192
195
  forceRefresh: shouldForceItemTypeSummaryRefresh,
196
+ prefetchedSummary: confirmationSaveVersion === 0 ? initialConfirmationSummary : undefined,
193
197
  }).catch((err) => {
194
198
  console.error(`Error fetching confirmation summary for case ${currentCase}:`, err);
195
199
  return null;
@@ -214,6 +218,7 @@ export const FilesModal = ({
214
218
  user,
215
219
  confirmationSaveVersion,
216
220
  shouldForceItemTypeSummaryRefresh,
221
+ initialConfirmationSummary,
217
222
  ]);
218
223
 
219
224
  const toggleDeleteSelection = (fileId: string) => {
@@ -6,6 +6,7 @@ import { Link } from 'react-router';
6
6
  import { Sidebar } from './sidebar';
7
7
  import type { User } from 'firebase/auth';
8
8
  import { type FileData } from '~/types';
9
+ import { type UserConfirmationSummaryDocument } from '~/utils/data';
9
10
  import styles from './sidebar.module.css';
10
11
  import { getAppVersion } from '~/utils/common';
11
12
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
@@ -30,6 +31,7 @@ interface SidebarContainerProps {
30
31
  isUploading?: boolean;
31
32
  onUploadStatusChange?: (isUploading: boolean) => void;
32
33
  onOpenCaseExport?: () => void;
34
+ initialConfirmationSummary?: UserConfirmationSummaryDocument;
33
35
  }
34
36
 
35
37
  export const SidebarContainer: React.FC<SidebarContainerProps> = (props) => {