@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.
- package/LICENSE +1 -1
- package/app/components/actions/case-manage.ts +50 -17
- package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
- package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
- package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
- package/app/components/canvas/confirmation/confirmation.tsx +6 -2
- package/app/components/colors/colors.module.css +4 -3
- package/app/components/navbar/navbar.tsx +34 -9
- package/app/components/sidebar/cases/case-sidebar.tsx +44 -70
- package/app/components/sidebar/cases/cases-modal.tsx +76 -35
- package/app/components/sidebar/cases/cases.module.css +20 -0
- package/app/components/sidebar/files/files-modal.tsx +37 -39
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +37 -74
- package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
- package/app/components/sidebar/notes/notes.module.css +27 -11
- package/app/components/sidebar/sidebar-container.tsx +1 -0
- package/app/components/sidebar/sidebar.tsx +3 -0
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useOverlayDismiss.ts +6 -4
- package/app/root.tsx +1 -1
- package/app/routes/striae/striae.tsx +6 -0
- package/app/services/audit/audit.service.ts +2 -2
- package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
- package/app/types/audit.ts +1 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
- package/app/utils/data/data-operations.ts +17 -861
- package/app/utils/data/index.ts +11 -1
- package/app/utils/data/operations/batch-operations.ts +113 -0
- package/app/utils/data/operations/case-operations.ts +168 -0
- package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
- package/app/utils/data/operations/file-annotation-operations.ts +196 -0
- package/app/utils/data/operations/index.ts +7 -0
- package/app/utils/data/operations/signing-operations.ts +225 -0
- package/app/utils/data/operations/types.ts +42 -0
- package/app/utils/data/operations/validation-operations.ts +48 -0
- package/app/utils/forensics/export-verification.ts +40 -111
- package/functions/api/_shared/firebase-auth.ts +2 -7
- package/functions/api/image/[[path]].ts +20 -23
- package/functions/api/pdf/[[path]].ts +27 -8
- package/package.json +5 -10
- package/scripts/deploy-primershear-emails.sh +1 -1
- 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/src/image-worker.example.ts +16 -5
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +1 -7
- package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
- package/workers/pdf-worker/src/report-types.ts +3 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/src/user-worker.example.ts +17 -0
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/NOTICE +0 -13
- package/app/components/sidebar/notes/notes-modal.tsx +0 -52
- package/postcss.config.js +0 -6
- package/tailwind.config.ts +0 -22
package/LICENSE
CHANGED
|
@@ -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
|
|
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
|
|
19
|
-
<div
|
|
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
|
|
116
|
-
|
|
115
|
+
const isArchiveBundleCase = Boolean(
|
|
116
|
+
caseData?.archived === true &&
|
|
117
|
+
caseData?.bundledAuditTrail?.source === 'archive-bundle'
|
|
118
|
+
);
|
|
119
|
+
setIsArchivedReadOnlyCase(isArchiveBundleCase);
|
|
117
120
|
|
|
118
|
-
if (
|
|
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
|
);
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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 ? '
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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(
|
|
174
|
-
setCaseConfirmationStatus(
|
|
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
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
?
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 {
|
|
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 = ({
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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:
|
|
110
|
-
isConfirmed:
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
statuses[result.caseNum] = {
|
|
129
|
-
includeConfirmation: result.includeConfirmation,
|
|
130
|
-
isConfirmed: result.isConfirmed,
|
|
131
|
-
};
|
|
132
|
-
});
|
|
157
|
+
if (isCancelled) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
133
160
|
|
|
134
|
-
setCaseConfirmationStatus(
|
|
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
|
-
|
|
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
|
|