@striae-org/striae 6.1.3 → 6.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/actions/case-manage/operations.ts +17 -0
- package/app/components/auth/mfa-enrollment.tsx +10 -8
- package/app/components/auth/mfa-verification.tsx +10 -10
- package/app/components/navbar/case-modals/all-cases-modal.tsx +93 -109
- package/app/components/sidebar/cases/case-sidebar.tsx +40 -22
- package/app/components/sidebar/files/files-modal.tsx +5 -0
- package/app/components/sidebar/sidebar-container.tsx +2 -0
- package/app/components/sidebar/sidebar.tsx +4 -0
- package/app/components/user/mfa-phone-update.tsx +11 -9
- package/app/components/user/mfa-totp-section.tsx +8 -7
- package/app/routes/auth/login.example.tsx +18 -18
- package/app/routes/striae/striae.tsx +82 -46
- package/app/utils/data/confirmation-summary/summary-core.ts +6 -0
- package/app/utils/data/operations/confirmation-summary-operations.ts +3 -1
- package/app/utils/data/permissions.ts +29 -4
- package/package.json +2 -2
- package/worker-configuration.d.ts +2 -2
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -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
|
|
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
|
-
|
|
62
|
+
recaptchaVerifierRef.current = verifier;
|
|
63
63
|
|
|
64
64
|
return () => {
|
|
65
65
|
verifier.clear();
|
|
66
|
+
recaptchaVerifierRef.current = null;
|
|
66
67
|
};
|
|
67
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
let isCancelled = false;
|
|
160
112
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
203
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 (!
|
|
354
|
+
if (!effectiveSelectedCaseNumber) {
|
|
371
355
|
return;
|
|
372
356
|
}
|
|
373
357
|
|
|
374
|
-
onSelectCase(
|
|
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 =
|
|
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={
|
|
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={!
|
|
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={
|
|
802
|
+
disabled={effectiveCurrentPage === 0}
|
|
819
803
|
>
|
|
820
804
|
Previous
|
|
821
805
|
</button>
|
|
822
|
-
<span>{
|
|
806
|
+
<span>{effectiveCurrentPage + 1} of {totalPages} ({visibleCases.length} filtered cases)</span>
|
|
823
807
|
<button
|
|
824
808
|
onClick={() => setCurrentPage(p => p + 1)}
|
|
825
|
-
disabled={
|
|
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
|
-
|
|
103
|
-
|
|
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(
|
|
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) => {
|