@striae-org/striae 4.2.0 → 4.3.0
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/case-modals/archive-case-modal.module.css +0 -76
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
- package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
- package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
- package/app/components/navbar/navbar.tsx +34 -9
- package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
- package/app/components/sidebar/cases/cases-modal.module.css +312 -10
- package/app/components/sidebar/cases/cases-modal.tsx +737 -116
- package/app/components/sidebar/cases/cases.module.css +43 -0
- package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
- package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
- package/app/components/sidebar/files/files-modal.module.css +285 -44
- package/app/components/sidebar/files/files-modal.tsx +482 -177
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
- package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
- package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
- package/app/components/sidebar/notes/class-details-shared.ts +239 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
- package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
- package/app/components/sidebar/notes/notes.module.css +262 -14
- package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
- package/app/components/sidebar/sidebar-container.tsx +2 -0
- package/app/components/sidebar/sidebar.tsx +15 -1
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useCaseListPreferences.ts +99 -0
- package/app/hooks/useFileListPreferences.ts +106 -0
- package/app/hooks/useOverlayDismiss.ts +6 -4
- package/app/root.tsx +1 -1
- package/app/routes/striae/striae.tsx +7 -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/annotations.ts +48 -1
- package/app/types/audit.ts +1 -0
- package/app/utils/data/case-filters.ts +127 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
- package/app/utils/data/data-operations.ts +17 -861
- package/app/utils/data/file-filters.ts +201 -0
- 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 +23 -22
- package/functions/api/pdf/[[path]].ts +27 -8
- package/package.json +7 -13
- 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 +84 -124
- package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
- package/workers/pdf-worker/src/report-layout.ts +227 -0
- package/workers/pdf-worker/src/report-types.ts +23 -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/workers/pdf-worker/src/assets/icon-256.png +0 -0
- /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { useContext, useEffect, useMemo, useState } from 'react';
|
|
3
3
|
import { AuthContext } from '~/contexts/auth.context';
|
|
4
4
|
import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
5
|
+
import {
|
|
6
|
+
useFileListPreferences,
|
|
7
|
+
DEFAULT_FILES_MODAL_PREFERENCES,
|
|
8
|
+
} from '~/hooks/useFileListPreferences';
|
|
9
|
+
import {
|
|
10
|
+
type FilesModalSortBy,
|
|
11
|
+
type FilesModalConfirmationFilter,
|
|
12
|
+
type FilesModalClassTypeFilter,
|
|
13
|
+
getFilesForModal,
|
|
14
|
+
} from '~/utils/data/file-filters';
|
|
5
15
|
import { deleteFile } from '~/components/actions/image-manage';
|
|
6
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ensureCaseConfirmationSummary,
|
|
18
|
+
type FileConfirmationSummary,
|
|
19
|
+
} from '~/utils/data';
|
|
7
20
|
import { type FileData } from '~/types';
|
|
21
|
+
import { DeleteFilesModal } from './delete-files-modal';
|
|
8
22
|
import styles from './files-modal.module.css';
|
|
9
23
|
|
|
10
24
|
interface FilesModalProps {
|
|
@@ -16,244 +30,535 @@ interface FilesModalProps {
|
|
|
16
30
|
setFiles: React.Dispatch<React.SetStateAction<FileData[]>>;
|
|
17
31
|
isReadOnly?: boolean;
|
|
18
32
|
selectedFileId?: string;
|
|
33
|
+
confirmationSaveVersion?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ActionNotice {
|
|
37
|
+
type: 'success' | 'warning' | 'error';
|
|
38
|
+
message: string;
|
|
19
39
|
}
|
|
20
40
|
|
|
21
41
|
const FILES_PER_PAGE = 10;
|
|
22
42
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
43
|
+
const CLEAR_SELECTION_FILE: FileData = {
|
|
44
|
+
id: 'clear',
|
|
45
|
+
originalFilename: '/clear.jpg',
|
|
46
|
+
uploadedAt: '',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DEFAULT_CONFIRMATION_SUMMARY: FileConfirmationSummary = {
|
|
50
|
+
includeConfirmation: false,
|
|
51
|
+
isConfirmed: false,
|
|
52
|
+
updatedAt: '',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function formatDate(dateString: string): string {
|
|
56
|
+
const parsed = Date.parse(dateString);
|
|
57
|
+
if (Number.isNaN(parsed)) {
|
|
58
|
+
return 'Unknown';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new Date(parsed).toLocaleDateString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getClassTypeLabel(classType?: FileConfirmationSummary['classType']): string {
|
|
65
|
+
if (!classType) {
|
|
66
|
+
return 'Unset';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return classType;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getConfirmationLabel(summary: FileConfirmationSummary): string {
|
|
73
|
+
if (!summary.includeConfirmation) {
|
|
74
|
+
return 'None Requested';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return summary.isConfirmed ? 'Confirmed' : 'Pending';
|
|
29
78
|
}
|
|
30
79
|
|
|
31
|
-
export const FilesModal = ({
|
|
80
|
+
export const FilesModal = ({
|
|
81
|
+
isOpen,
|
|
82
|
+
onClose,
|
|
83
|
+
onFileSelect,
|
|
84
|
+
currentCase,
|
|
85
|
+
files,
|
|
86
|
+
setFiles,
|
|
87
|
+
isReadOnly = false,
|
|
88
|
+
selectedFileId,
|
|
89
|
+
confirmationSaveVersion = 0,
|
|
90
|
+
}: FilesModalProps) => {
|
|
32
91
|
const { user } = useContext(AuthContext);
|
|
33
|
-
const [error, setError] = useState<string | null>(null);
|
|
34
92
|
const [currentPage, setCurrentPage] = useState(0);
|
|
35
|
-
const [
|
|
36
|
-
const [
|
|
93
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
94
|
+
const [openSelectedFileId, setOpenSelectedFileId] = useState<string | null>(selectedFileId || null);
|
|
95
|
+
const [deleteSelectedFileIds, setDeleteSelectedFileIds] = useState<Set<string>>(new Set());
|
|
96
|
+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
97
|
+
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
|
|
98
|
+
const [actionNotice, setActionNotice] = useState<ActionNotice | null>(null);
|
|
99
|
+
const [fileConfirmationStatus, setFileConfirmationStatus] = useState<Record<string, FileConfirmationSummary>>({});
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!actionNotice) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const timer = window.setTimeout(() => setActionNotice(null), 5000);
|
|
107
|
+
return () => window.clearTimeout(timer);
|
|
108
|
+
}, [actionNotice]);
|
|
109
|
+
const {
|
|
110
|
+
preferences,
|
|
111
|
+
setSortBy,
|
|
112
|
+
setConfirmationFilter,
|
|
113
|
+
setClassTypeFilter,
|
|
114
|
+
resetPreferences,
|
|
115
|
+
} = useFileListPreferences();
|
|
37
116
|
const {
|
|
38
117
|
requestClose,
|
|
39
118
|
overlayProps,
|
|
40
|
-
getCloseButtonProps
|
|
119
|
+
getCloseButtonProps,
|
|
41
120
|
} = useOverlayDismiss({
|
|
42
121
|
isOpen,
|
|
43
|
-
onClose
|
|
122
|
+
onClose,
|
|
44
123
|
});
|
|
45
124
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
125
|
+
const hasCustomPreferences =
|
|
126
|
+
preferences.sortBy !== DEFAULT_FILES_MODAL_PREFERENCES.sortBy ||
|
|
127
|
+
preferences.confirmationFilter !== DEFAULT_FILES_MODAL_PREFERENCES.confirmationFilter ||
|
|
128
|
+
preferences.classTypeFilter !== DEFAULT_FILES_MODAL_PREFERENCES.classTypeFilter;
|
|
129
|
+
|
|
130
|
+
const existingFileIdSet = useMemo(
|
|
131
|
+
() => new Set(files.map((file) => file.id)),
|
|
132
|
+
[files]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const effectiveDeleteSelectedFileIds = useMemo(
|
|
136
|
+
() => new Set(Array.from(deleteSelectedFileIds).filter((fileId) => existingFileIdSet.has(fileId))),
|
|
137
|
+
[deleteSelectedFileIds, existingFileIdSet]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const effectiveOpenSelectedFileId = useMemo(() => {
|
|
141
|
+
if (openSelectedFileId && existingFileIdSet.has(openSelectedFileId)) {
|
|
142
|
+
return openSelectedFileId;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (selectedFileId && existingFileIdSet.has(selectedFileId)) {
|
|
146
|
+
return selectedFileId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}, [openSelectedFileId, selectedFileId, existingFileIdSet]);
|
|
151
|
+
|
|
152
|
+
const visibleFiles = useMemo(
|
|
153
|
+
() => getFilesForModal(files, preferences, fileConfirmationStatus, searchQuery),
|
|
154
|
+
[files, preferences, fileConfirmationStatus, searchQuery]
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const totalPages = Math.max(1, Math.ceil(visibleFiles.length / FILES_PER_PAGE));
|
|
158
|
+
const effectiveCurrentPage = Math.min(currentPage, totalPages - 1);
|
|
159
|
+
|
|
160
|
+
const paginatedFiles = visibleFiles.slice(
|
|
161
|
+
effectiveCurrentPage * FILES_PER_PAGE,
|
|
162
|
+
(effectiveCurrentPage + 1) * FILES_PER_PAGE
|
|
163
|
+
);
|
|
50
164
|
|
|
51
|
-
// Fetch confirmation status only for currently visible paginated files
|
|
52
165
|
useEffect(() => {
|
|
53
|
-
|
|
54
|
-
const visibleFiles = files.slice(
|
|
55
|
-
currentPage * FILES_PER_PAGE,
|
|
56
|
-
currentPage * FILES_PER_PAGE + FILES_PER_PAGE
|
|
57
|
-
);
|
|
166
|
+
let isCancelled = false;
|
|
58
167
|
|
|
59
|
-
|
|
168
|
+
const fetchConfirmationStatuses = async () => {
|
|
169
|
+
if (!isOpen || !currentCase || !user || files.length === 0) {
|
|
170
|
+
if (!isCancelled) {
|
|
171
|
+
setFileConfirmationStatus({});
|
|
172
|
+
}
|
|
60
173
|
return;
|
|
61
174
|
}
|
|
62
175
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const annotations = await getFileAnnotations(user, currentCase, file.id);
|
|
67
|
-
return {
|
|
68
|
-
fileId: file.id,
|
|
69
|
-
includeConfirmation: annotations?.includeConfirmation ?? false,
|
|
70
|
-
isConfirmed: !!(annotations?.includeConfirmation && annotations?.confirmationData),
|
|
71
|
-
};
|
|
72
|
-
} catch (err) {
|
|
73
|
-
console.error(`Error fetching annotations for file ${file.id}:`, err);
|
|
74
|
-
return {
|
|
75
|
-
fileId: file.id,
|
|
76
|
-
includeConfirmation: false,
|
|
77
|
-
isConfirmed: false,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
176
|
+
const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files).catch((err) => {
|
|
177
|
+
console.error(`Error fetching confirmation summary for case ${currentCase}:`, err);
|
|
178
|
+
return null;
|
|
80
179
|
});
|
|
81
180
|
|
|
82
|
-
|
|
83
|
-
|
|
181
|
+
if (!caseSummary || isCancelled) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
setFileConfirmationStatus(caseSummary.filesById);
|
|
186
|
+
};
|
|
84
187
|
|
|
85
|
-
|
|
86
|
-
const statuses: FileConfirmationStatus = {};
|
|
87
|
-
results.forEach((result) => {
|
|
88
|
-
statuses[result.fileId] = {
|
|
89
|
-
includeConfirmation: result.includeConfirmation,
|
|
90
|
-
isConfirmed: result.isConfirmed,
|
|
91
|
-
};
|
|
92
|
-
});
|
|
188
|
+
void fetchConfirmationStatuses();
|
|
93
189
|
|
|
94
|
-
|
|
190
|
+
return () => {
|
|
191
|
+
isCancelled = true;
|
|
95
192
|
};
|
|
193
|
+
}, [isOpen, currentCase, files, user, confirmationSaveVersion]);
|
|
96
194
|
|
|
97
|
-
|
|
98
|
-
|
|
195
|
+
const toggleDeleteSelection = (fileId: string) => {
|
|
196
|
+
setDeleteSelectedFileIds((previous) => {
|
|
197
|
+
const next = new Set(previous);
|
|
198
|
+
if (next.has(fileId)) {
|
|
199
|
+
next.delete(fileId);
|
|
200
|
+
} else {
|
|
201
|
+
next.add(fileId);
|
|
202
|
+
}
|
|
203
|
+
return next;
|
|
204
|
+
});
|
|
205
|
+
};
|
|
99
206
|
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
207
|
+
const selectAllVisibleFiles = () => {
|
|
208
|
+
setDeleteSelectedFileIds((previous) => {
|
|
209
|
+
const next = new Set(previous);
|
|
210
|
+
visibleFiles.forEach((file) => {
|
|
211
|
+
next.add(file.id);
|
|
212
|
+
});
|
|
213
|
+
return next;
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const clearDeleteSelection = () => {
|
|
218
|
+
setDeleteSelectedFileIds(new Set());
|
|
103
219
|
};
|
|
104
220
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Don't allow file deletion for read-only cases
|
|
109
|
-
if (isReadOnly) {
|
|
221
|
+
const handleOpenSelectedFile = () => {
|
|
222
|
+
if (!openSelectedFileId) {
|
|
110
223
|
return;
|
|
111
224
|
}
|
|
112
|
-
|
|
113
|
-
|
|
225
|
+
|
|
226
|
+
const targetFile = files.find((file) => file.id === openSelectedFileId);
|
|
227
|
+
if (!targetFile) {
|
|
228
|
+
setActionNotice({
|
|
229
|
+
type: 'error',
|
|
230
|
+
message: 'Selected file is no longer available.',
|
|
231
|
+
});
|
|
114
232
|
return;
|
|
115
233
|
}
|
|
116
234
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
235
|
+
onFileSelect?.(targetFile);
|
|
236
|
+
requestClose();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const handleDeleteSelectedFiles = async () => {
|
|
240
|
+
if (!user || !currentCase || isReadOnly || deleteSelectedFileIds.size === 0) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
setIsDeletingSelected(true);
|
|
245
|
+
setActionNotice(null);
|
|
246
|
+
|
|
247
|
+
const selectedIds = Array.from(deleteSelectedFileIds);
|
|
248
|
+
const failedFiles: string[] = [];
|
|
249
|
+
const deletedIds: string[] = [];
|
|
250
|
+
let missingImages = 0;
|
|
251
|
+
|
|
252
|
+
for (const fileId of selectedIds) {
|
|
253
|
+
try {
|
|
254
|
+
const result = await deleteFile(user, currentCase, fileId);
|
|
255
|
+
deletedIds.push(fileId);
|
|
256
|
+
if (result.imageMissing) {
|
|
257
|
+
missingImages += 1;
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
const failedFile = files.find((file) => file.id === fileId);
|
|
261
|
+
failedFiles.push(failedFile?.originalFilename || fileId);
|
|
134
262
|
}
|
|
135
|
-
} catch (err) {
|
|
136
|
-
console.error('Error deleting file:', err);
|
|
137
|
-
setError('Failed to delete file');
|
|
138
|
-
setTimeout(() => setError(null), 3000);
|
|
139
|
-
} finally {
|
|
140
|
-
setDeletingFileId(null);
|
|
141
263
|
}
|
|
142
|
-
};
|
|
143
264
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
265
|
+
const updatedFiles = files.filter((file) => !deletedIds.includes(file.id));
|
|
266
|
+
setFiles(updatedFiles);
|
|
267
|
+
|
|
268
|
+
setFileConfirmationStatus((previous) => {
|
|
269
|
+
const next = { ...previous };
|
|
270
|
+
deletedIds.forEach((fileId) => {
|
|
271
|
+
delete next[fileId];
|
|
272
|
+
});
|
|
273
|
+
return next;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
setDeleteSelectedFileIds(new Set());
|
|
277
|
+
setIsDeleteModalOpen(false);
|
|
278
|
+
|
|
279
|
+
if (selectedFileId && deletedIds.includes(selectedFileId)) {
|
|
280
|
+
onFileSelect?.(CLEAR_SELECTION_FILE);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (effectiveOpenSelectedFileId && deletedIds.includes(effectiveOpenSelectedFileId)) {
|
|
284
|
+
setOpenSelectedFileId(null);
|
|
149
285
|
}
|
|
150
|
-
};
|
|
151
286
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
287
|
+
if (failedFiles.length > 0) {
|
|
288
|
+
setActionNotice({
|
|
289
|
+
type: 'warning',
|
|
290
|
+
message: `Deleted ${deletedIds.length} file(s). Failed: ${failedFiles.join(', ')}`,
|
|
291
|
+
});
|
|
292
|
+
} else if (missingImages > 0) {
|
|
293
|
+
setActionNotice({
|
|
294
|
+
type: 'warning',
|
|
295
|
+
message: `Deleted ${deletedIds.length} file(s). ${missingImages} image asset(s) were missing and skipped.`,
|
|
296
|
+
});
|
|
297
|
+
} else {
|
|
298
|
+
setActionNotice({
|
|
299
|
+
type: 'success',
|
|
300
|
+
message: `Deleted ${deletedIds.length} file(s) successfully.`,
|
|
301
|
+
});
|
|
157
302
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const maxNameLength = 27 - ext.length;
|
|
161
|
-
if (name.length <= maxNameLength) return filename;
|
|
162
|
-
return name.substring(0, maxNameLength) + '…' + ext;
|
|
303
|
+
|
|
304
|
+
setIsDeletingSelected(false);
|
|
163
305
|
};
|
|
164
306
|
|
|
165
|
-
|
|
307
|
+
const canDeleteSelected = !isReadOnly && effectiveDeleteSelectedFileIds.size > 0 && !isDeletingSelected;
|
|
308
|
+
|
|
309
|
+
if (!isOpen) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
166
312
|
|
|
167
313
|
return (
|
|
168
|
-
<div
|
|
169
|
-
className={styles.modalOverlay}
|
|
170
|
-
aria-label="Close files dialog"
|
|
171
|
-
{...overlayProps}
|
|
172
|
-
>
|
|
314
|
+
<div className={styles.modalOverlay} aria-label="Close files dialog" {...overlayProps}>
|
|
173
315
|
<div className={styles.modal}>
|
|
174
|
-
<
|
|
175
|
-
<h2>
|
|
316
|
+
<header className={styles.modalHeader}>
|
|
317
|
+
<h2>File Management {currentCase ? `- ${currentCase}` : ''}</h2>
|
|
176
318
|
<button className={styles.closeButton} {...getCloseButtonProps({ ariaLabel: 'Close files dialog' })}>
|
|
177
319
|
×
|
|
178
320
|
</button>
|
|
179
|
-
</
|
|
180
|
-
|
|
321
|
+
</header>
|
|
322
|
+
|
|
181
323
|
<div className={styles.modalContent}>
|
|
182
|
-
{
|
|
183
|
-
<
|
|
184
|
-
) : files.length === 0 ? (
|
|
185
|
-
<div className={styles.emptyState}>No files in this case</div>
|
|
324
|
+
{files.length === 0 ? (
|
|
325
|
+
<p className={styles.emptyState}>No files found in this case</p>
|
|
186
326
|
) : (
|
|
187
|
-
|
|
188
|
-
{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return (
|
|
199
|
-
<div
|
|
200
|
-
key={file.id}
|
|
201
|
-
className={`${styles.fileItem} ${selectedFileId === file.id ? styles.active : ''} ${confirmationClass}`}
|
|
202
|
-
onClick={() => handleFileSelect(file)}
|
|
203
|
-
onKeyDown={(event) => {
|
|
204
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
205
|
-
event.preventDefault();
|
|
206
|
-
handleFileSelect(file);
|
|
207
|
-
}
|
|
327
|
+
<>
|
|
328
|
+
<section className={styles.controlsSection} aria-label="File list controls">
|
|
329
|
+
<div className={styles.controlGroup}>
|
|
330
|
+
<label htmlFor="files-sort">Sort</label>
|
|
331
|
+
<select
|
|
332
|
+
id="files-sort"
|
|
333
|
+
value={preferences.sortBy}
|
|
334
|
+
onChange={(event) => {
|
|
335
|
+
setSortBy(event.target.value as FilesModalSortBy);
|
|
336
|
+
setCurrentPage(0);
|
|
208
337
|
}}
|
|
209
|
-
role="button"
|
|
210
|
-
tabIndex={0}
|
|
211
338
|
>
|
|
212
|
-
<
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
</
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
339
|
+
<option value="recent">Date Uploaded</option>
|
|
340
|
+
<option value="filename">File Name</option>
|
|
341
|
+
<option value="confirmation">Confirmation Status</option>
|
|
342
|
+
<option value="classType">Class Type</option>
|
|
343
|
+
</select>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
<div className={styles.controlGroup}>
|
|
347
|
+
<label htmlFor="files-confirmation-filter">Confirmation Status</label>
|
|
348
|
+
<select
|
|
349
|
+
id="files-confirmation-filter"
|
|
350
|
+
value={preferences.confirmationFilter}
|
|
351
|
+
onChange={(event) => {
|
|
352
|
+
setConfirmationFilter(event.target.value as FilesModalConfirmationFilter);
|
|
353
|
+
setCurrentPage(0);
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
<option value="all">All</option>
|
|
357
|
+
<option value="pending">Pending</option>
|
|
358
|
+
<option value="confirmed">Confirmed</option>
|
|
359
|
+
<option value="none-requested">None Requested</option>
|
|
360
|
+
</select>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<div className={styles.controlGroup}>
|
|
364
|
+
<label htmlFor="files-class-filter">Class Type</label>
|
|
365
|
+
<select
|
|
366
|
+
id="files-class-filter"
|
|
367
|
+
value={preferences.classTypeFilter}
|
|
368
|
+
onChange={(event) => {
|
|
369
|
+
setClassTypeFilter(event.target.value as FilesModalClassTypeFilter);
|
|
370
|
+
setCurrentPage(0);
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
<option value="all">All</option>
|
|
374
|
+
<option value="Bullet">Bullet</option>
|
|
375
|
+
<option value="Cartridge Case">Cartridge Case</option>
|
|
376
|
+
<option value="Shotshell">Shotshell</option>
|
|
377
|
+
<option value="Other">Other</option>
|
|
378
|
+
</select>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
className={styles.resetButton}
|
|
384
|
+
onClick={() => {
|
|
385
|
+
setSearchQuery('');
|
|
386
|
+
resetPreferences();
|
|
387
|
+
setCurrentPage(0);
|
|
388
|
+
}}
|
|
389
|
+
disabled={!hasCustomPreferences && searchQuery.trim().length === 0}
|
|
390
|
+
>
|
|
391
|
+
Reset
|
|
392
|
+
</button>
|
|
393
|
+
</section>
|
|
394
|
+
|
|
395
|
+
<div className={styles.searchSection}>
|
|
396
|
+
<label htmlFor="file-search">Search file name</label>
|
|
397
|
+
<input
|
|
398
|
+
id="file-search"
|
|
399
|
+
type="text"
|
|
400
|
+
value={searchQuery}
|
|
401
|
+
onChange={(event) => {
|
|
402
|
+
setSearchQuery(event.target.value);
|
|
403
|
+
setCurrentPage(0);
|
|
404
|
+
}}
|
|
405
|
+
placeholder="Type to filter files"
|
|
406
|
+
className={styles.searchInput}
|
|
407
|
+
/>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<p className={styles.fileCount}>
|
|
411
|
+
{visibleFiles.length} shown of {files.length} total files
|
|
412
|
+
</p>
|
|
413
|
+
|
|
414
|
+
{actionNotice && (
|
|
415
|
+
<p
|
|
416
|
+
className={`${styles.actionNotice} ${
|
|
417
|
+
actionNotice.type === 'error'
|
|
418
|
+
? styles.actionNoticeError
|
|
419
|
+
: actionNotice.type === 'warning'
|
|
420
|
+
? styles.actionNoticeWarning
|
|
421
|
+
: styles.actionNoticeSuccess
|
|
422
|
+
}`}
|
|
423
|
+
>
|
|
424
|
+
{actionNotice.message}
|
|
425
|
+
</p>
|
|
426
|
+
)}
|
|
427
|
+
|
|
428
|
+
{visibleFiles.length === 0 ? (
|
|
429
|
+
<p className={styles.emptyState}>No files match your filters</p>
|
|
430
|
+
) : (
|
|
431
|
+
<ul className={styles.filesList}>
|
|
432
|
+
{paginatedFiles.map((file) => {
|
|
433
|
+
const summary = fileConfirmationStatus[file.id] || DEFAULT_CONFIRMATION_SUMMARY;
|
|
434
|
+
const isOpenSelected = effectiveOpenSelectedFileId === file.id;
|
|
435
|
+
const isDeleteSelected = effectiveDeleteSelectedFileIds.has(file.id);
|
|
436
|
+
const confirmationLabel = getConfirmationLabel(summary);
|
|
437
|
+
const classTypeLabel = getClassTypeLabel(summary.classType);
|
|
438
|
+
|
|
439
|
+
let confirmationClass = '';
|
|
440
|
+
if (summary.includeConfirmation) {
|
|
441
|
+
confirmationClass = summary.isConfirmed
|
|
442
|
+
? styles.fileItemConfirmed
|
|
443
|
+
: styles.fileItemNotConfirmed;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return (
|
|
447
|
+
<li key={file.id}>
|
|
448
|
+
<div
|
|
449
|
+
className={`${styles.fileItem} ${isOpenSelected ? styles.active : ''}`}
|
|
450
|
+
onClick={() => setOpenSelectedFileId(file.id)}
|
|
451
|
+
onKeyDown={(event) => {
|
|
452
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
453
|
+
event.preventDefault();
|
|
454
|
+
setOpenSelectedFileId(file.id);
|
|
455
|
+
}
|
|
456
|
+
}}
|
|
457
|
+
role="button"
|
|
458
|
+
tabIndex={0}
|
|
459
|
+
>
|
|
460
|
+
<input
|
|
461
|
+
type="checkbox"
|
|
462
|
+
checked={isDeleteSelected}
|
|
463
|
+
className={styles.deleteSelector}
|
|
464
|
+
onChange={() => toggleDeleteSelection(file.id)}
|
|
465
|
+
onClick={(event) => event.stopPropagation()}
|
|
466
|
+
aria-label={`Select ${file.originalFilename} for delete`}
|
|
467
|
+
/>
|
|
468
|
+
|
|
469
|
+
<div className={styles.fileInfo}>
|
|
470
|
+
<div className={styles.fileName} title={file.originalFilename}>
|
|
471
|
+
{file.originalFilename}
|
|
472
|
+
</div>
|
|
473
|
+
<div className={styles.fileMetaRow}>
|
|
474
|
+
<span className={styles.fileDate}>Uploaded: {formatDate(file.uploadedAt)}</span>
|
|
475
|
+
<span className={styles.classTypeBadge}>Class: {classTypeLabel}</span>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<span
|
|
480
|
+
className={`${styles.confirmationBadge} ${confirmationClass}`}
|
|
481
|
+
aria-label={`Confirmation status: ${confirmationLabel}`}
|
|
482
|
+
>
|
|
483
|
+
{confirmationLabel}
|
|
484
|
+
</span>
|
|
485
|
+
</div>
|
|
486
|
+
</li>
|
|
487
|
+
);
|
|
488
|
+
})}
|
|
489
|
+
</ul>
|
|
490
|
+
)}
|
|
491
|
+
</>
|
|
234
492
|
)}
|
|
235
493
|
</div>
|
|
236
|
-
|
|
237
|
-
{
|
|
238
|
-
<div className={styles.
|
|
494
|
+
|
|
495
|
+
<div className={styles.footerActions}>
|
|
496
|
+
<div className={styles.maintenanceActions}>
|
|
497
|
+
<button
|
|
498
|
+
type="button"
|
|
499
|
+
className={styles.secondaryActionButton}
|
|
500
|
+
onClick={selectAllVisibleFiles}
|
|
501
|
+
disabled={isReadOnly || visibleFiles.length === 0 || isDeletingSelected}
|
|
502
|
+
>
|
|
503
|
+
Select Visible
|
|
504
|
+
</button>
|
|
239
505
|
<button
|
|
240
|
-
|
|
241
|
-
|
|
506
|
+
type="button"
|
|
507
|
+
className={styles.secondaryActionButton}
|
|
508
|
+
onClick={clearDeleteSelection}
|
|
509
|
+
disabled={effectiveDeleteSelectedFileIds.size === 0 || isDeletingSelected}
|
|
242
510
|
>
|
|
243
|
-
|
|
511
|
+
Clear Selected
|
|
244
512
|
</button>
|
|
245
|
-
<span>
|
|
246
|
-
Page {currentPage + 1} of {totalPages} ({files.length} total files)
|
|
247
|
-
</span>
|
|
248
513
|
<button
|
|
249
|
-
|
|
250
|
-
|
|
514
|
+
type="button"
|
|
515
|
+
className={`${styles.secondaryActionButton} ${styles.deleteActionButton}`}
|
|
516
|
+
onClick={() => setIsDeleteModalOpen(true)}
|
|
517
|
+
disabled={!canDeleteSelected}
|
|
251
518
|
>
|
|
252
|
-
|
|
519
|
+
Delete Selected ({effectiveDeleteSelectedFileIds.size})
|
|
253
520
|
</button>
|
|
254
521
|
</div>
|
|
255
|
-
|
|
522
|
+
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
className={styles.openSelectedButton}
|
|
526
|
+
onClick={handleOpenSelectedFile}
|
|
527
|
+
disabled={!effectiveOpenSelectedFileId || isDeletingSelected}
|
|
528
|
+
>
|
|
529
|
+
Open Selected File
|
|
530
|
+
</button>
|
|
531
|
+
|
|
532
|
+
{totalPages > 1 && (
|
|
533
|
+
<div className={styles.pagination}>
|
|
534
|
+
<button
|
|
535
|
+
onClick={() => setCurrentPage((previous) => Math.max(0, previous - 1))}
|
|
536
|
+
disabled={effectiveCurrentPage === 0}
|
|
537
|
+
>
|
|
538
|
+
Previous
|
|
539
|
+
</button>
|
|
540
|
+
<span>
|
|
541
|
+
{effectiveCurrentPage + 1} of {totalPages} ({visibleFiles.length} filtered files)
|
|
542
|
+
</span>
|
|
543
|
+
<button
|
|
544
|
+
onClick={() => setCurrentPage((previous) => Math.min(totalPages - 1, previous + 1))}
|
|
545
|
+
disabled={effectiveCurrentPage === totalPages - 1}
|
|
546
|
+
>
|
|
547
|
+
Next
|
|
548
|
+
</button>
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
256
552
|
</div>
|
|
553
|
+
|
|
554
|
+
<DeleteFilesModal
|
|
555
|
+
isOpen={isDeleteModalOpen}
|
|
556
|
+
isSubmitting={isDeletingSelected}
|
|
557
|
+
files={files}
|
|
558
|
+
selectedFileIds={effectiveDeleteSelectedFileIds}
|
|
559
|
+
onClose={() => setIsDeleteModalOpen(false)}
|
|
560
|
+
onSubmit={handleDeleteSelectedFiles}
|
|
561
|
+
/>
|
|
257
562
|
</div>
|
|
258
563
|
);
|
|
259
564
|
};
|