@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.
Files changed (90) hide show
  1. package/LICENSE +1 -1
  2. package/app/components/actions/case-manage.ts +50 -17
  3. package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
  4. package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
  5. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  6. package/app/components/canvas/confirmation/confirmation.tsx +6 -2
  7. package/app/components/colors/colors.module.css +4 -3
  8. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  9. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  10. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  11. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  12. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  13. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  14. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  15. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  16. package/app/components/navbar/navbar.tsx +34 -9
  17. package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
  18. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  19. package/app/components/sidebar/cases/cases-modal.tsx +737 -116
  20. package/app/components/sidebar/cases/cases.module.css +43 -0
  21. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  22. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  23. package/app/components/sidebar/files/files-modal.module.css +285 -44
  24. package/app/components/sidebar/files/files-modal.tsx +482 -177
  25. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  26. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  27. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  28. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  29. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  30. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
  31. package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
  32. package/app/components/sidebar/notes/notes.module.css +262 -14
  33. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  34. package/app/components/sidebar/sidebar-container.tsx +2 -0
  35. package/app/components/sidebar/sidebar.tsx +15 -1
  36. package/app/{tailwind.css → global.css} +1 -3
  37. package/app/hooks/useCaseListPreferences.ts +99 -0
  38. package/app/hooks/useFileListPreferences.ts +106 -0
  39. package/app/hooks/useOverlayDismiss.ts +6 -4
  40. package/app/root.tsx +1 -1
  41. package/app/routes/striae/striae.tsx +7 -0
  42. package/app/services/audit/audit.service.ts +2 -2
  43. package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
  44. package/app/types/annotations.ts +48 -1
  45. package/app/types/audit.ts +1 -0
  46. package/app/utils/data/case-filters.ts +127 -0
  47. package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
  48. package/app/utils/data/data-operations.ts +17 -861
  49. package/app/utils/data/file-filters.ts +201 -0
  50. package/app/utils/data/index.ts +11 -1
  51. package/app/utils/data/operations/batch-operations.ts +113 -0
  52. package/app/utils/data/operations/case-operations.ts +168 -0
  53. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  54. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  55. package/app/utils/data/operations/index.ts +7 -0
  56. package/app/utils/data/operations/signing-operations.ts +225 -0
  57. package/app/utils/data/operations/types.ts +42 -0
  58. package/app/utils/data/operations/validation-operations.ts +48 -0
  59. package/app/utils/forensics/export-verification.ts +40 -111
  60. package/functions/api/_shared/firebase-auth.ts +2 -7
  61. package/functions/api/image/[[path]].ts +23 -22
  62. package/functions/api/pdf/[[path]].ts +27 -8
  63. package/package.json +7 -13
  64. package/scripts/deploy-primershear-emails.sh +1 -1
  65. package/worker-configuration.d.ts +2 -2
  66. package/workers/audit-worker/package.json +1 -1
  67. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  68. package/workers/data-worker/package.json +1 -1
  69. package/workers/data-worker/wrangler.jsonc.example +1 -1
  70. package/workers/image-worker/package.json +1 -1
  71. package/workers/image-worker/src/image-worker.example.ts +16 -5
  72. package/workers/image-worker/wrangler.jsonc.example +1 -1
  73. package/workers/keys-worker/package.json +1 -1
  74. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  75. package/workers/pdf-worker/package.json +1 -1
  76. package/workers/pdf-worker/src/formats/format-striae.ts +84 -124
  77. package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
  78. package/workers/pdf-worker/src/report-layout.ts +227 -0
  79. package/workers/pdf-worker/src/report-types.ts +23 -3
  80. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  81. package/workers/user-worker/package.json +1 -1
  82. package/workers/user-worker/src/user-worker.example.ts +17 -0
  83. package/workers/user-worker/wrangler.jsonc.example +1 -1
  84. package/wrangler.toml.example +1 -1
  85. package/NOTICE +0 -13
  86. package/app/components/sidebar/notes/notes-modal.tsx +0 -52
  87. package/postcss.config.js +0 -6
  88. package/tailwind.config.ts +0 -22
  89. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  90. /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 { useState, useContext, useEffect } from 'react';
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 { getFileAnnotations } from '~/utils/data';
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
- // Type to track confirmation status for each file
24
- interface FileConfirmationStatus {
25
- [fileId: string]: {
26
- includeConfirmation: boolean;
27
- isConfirmed: boolean;
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 = ({ isOpen, onClose, onFileSelect, currentCase, files, setFiles, isReadOnly = false, selectedFileId }: FilesModalProps) => {
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 [deletingFileId, setDeletingFileId] = useState<string | null>(null);
36
- const [fileConfirmationStatus, setFileConfirmationStatus] = useState<FileConfirmationStatus>({});
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 totalPages = Math.ceil(files.length / FILES_PER_PAGE);
47
- const startIndex = currentPage * FILES_PER_PAGE;
48
- const endIndex = startIndex + FILES_PER_PAGE;
49
- const currentFiles = files.slice(startIndex, endIndex);
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
- const fetchConfirmationStatuses = async () => {
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
- if (!isOpen || !currentCase || !user || visibleFiles.length === 0) {
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
- // Fetch annotations in parallel for only visible files
64
- const annotationPromises = visibleFiles.map(async (file) => {
65
- try {
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
- // Wait for all fetches to complete
83
- const results = await Promise.all(annotationPromises);
181
+ if (!caseSummary || isCancelled) {
182
+ return;
183
+ }
184
+
185
+ setFileConfirmationStatus(caseSummary.filesById);
186
+ };
84
187
 
85
- // Build the statuses map from results
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
- setFileConfirmationStatus(statuses);
190
+ return () => {
191
+ isCancelled = true;
95
192
  };
193
+ }, [isOpen, currentCase, files, user, confirmationSaveVersion]);
96
194
 
97
- fetchConfirmationStatuses();
98
- }, [isOpen, currentCase, currentPage, files, user]);
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 handleFileSelect = (file: FileData) => {
101
- onFileSelect?.(file);
102
- requestClose();
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 handleDeleteFile = async (fileId: string, event: React.MouseEvent) => {
106
- event.stopPropagation(); // Prevent file selection when clicking delete
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
- if (!user || !currentCase || !window.confirm('Are you sure you want to delete this file?')) {
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
- setDeletingFileId(fileId);
118
-
119
- try {
120
- const deleteResult = await deleteFile(user, currentCase, fileId);
121
- // Remove the deleted file from the list
122
- const updatedFiles = files.filter(f => f.id !== fileId);
123
- setFiles(updatedFiles);
124
-
125
- if (deleteResult.imageMissing) {
126
- setError(`File record deleted. Image asset "${deleteResult.fileName}" was not found and was skipped.`);
127
- setTimeout(() => setError(null), 4000);
128
- }
129
-
130
- // Adjust page if needed
131
- const newTotalPages = Math.ceil(updatedFiles.length / FILES_PER_PAGE);
132
- if (currentPage >= newTotalPages && newTotalPages > 0) {
133
- setCurrentPage(newTotalPages - 1);
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
- const formatDate = (dateString: string) => {
145
- try {
146
- return new Date(dateString).toLocaleDateString();
147
- } catch {
148
- return 'Unknown';
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
- const formatFileName = (filename: string) => {
153
- if (filename.length <= 30) return filename;
154
- const lastDotIndex = filename.lastIndexOf('.');
155
- if (lastDotIndex === -1) {
156
- return filename.substring(0, 27) + '…';
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
- const name = filename.substring(0, lastDotIndex);
159
- const ext = filename.substring(lastDotIndex);
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
- if (!isOpen) return null;
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
- <div className={styles.modalHeader}>
175
- <h2>Files in Case {currentCase}</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
- </div>
180
-
321
+ </header>
322
+
181
323
  <div className={styles.modalContent}>
182
- {error ? (
183
- <div className={styles.errorState}>{error}</div>
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
- <div className={styles.filesList}>
188
- {currentFiles.map((file) => {
189
- const confirmationStatus = fileConfirmationStatus[file.id];
190
- let confirmationClass = '';
191
-
192
- if (confirmationStatus?.includeConfirmation) {
193
- confirmationClass = confirmationStatus.isConfirmed
194
- ? styles.fileItemConfirmed
195
- : styles.fileItemNotConfirmed;
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
- <div className={styles.fileInfo}>
213
- <div className={styles.fileName} title={file.originalFilename}>
214
- {formatFileName(file.originalFilename)}
215
- </div>
216
- <div className={styles.fileDate}>
217
- Uploaded: {formatDate(file.uploadedAt)}
218
- </div>
219
- </div>
220
- <button
221
- className={styles.deleteButton}
222
- onClick={(e) => handleDeleteFile(file.id, e)}
223
- disabled={isReadOnly || deletingFileId === file.id}
224
- aria-label={`Delete ${file.originalFilename}`}
225
- title={isReadOnly ? "Cannot delete files for read-only cases" : "Delete file"}
226
- style={{ opacity: isReadOnly ? 0.5 : 1, cursor: isReadOnly ? 'not-allowed' : 'pointer' }}
227
- >
228
- {deletingFileId === file.id ? '⏳' : '×'}
229
- </button>
230
- </div>
231
- );
232
- })}
233
- </div>
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
- {totalPages > 1 && (
238
- <div className={styles.pagination}>
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
- onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
241
- disabled={currentPage === 0}
506
+ type="button"
507
+ className={styles.secondaryActionButton}
508
+ onClick={clearDeleteSelection}
509
+ disabled={effectiveDeleteSelectedFileIds.size === 0 || isDeletingSelected}
242
510
  >
243
- Previous
511
+ Clear Selected
244
512
  </button>
245
- <span>
246
- Page {currentPage + 1} of {totalPages} ({files.length} total files)
247
- </span>
248
513
  <button
249
- onClick={() => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))}
250
- disabled={currentPage === totalPages - 1}
514
+ type="button"
515
+ className={`${styles.secondaryActionButton} ${styles.deleteActionButton}`}
516
+ onClick={() => setIsDeleteModalOpen(true)}
517
+ disabled={!canDeleteSelected}
251
518
  >
252
- Next
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
  };