@striae-org/striae 4.2.1 → 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 (47) hide show
  1. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  2. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  3. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  4. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  5. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  6. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  7. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  8. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  9. package/app/components/sidebar/cases/case-sidebar.tsx +49 -3
  10. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  11. package/app/components/sidebar/cases/cases-modal.tsx +690 -110
  12. package/app/components/sidebar/cases/cases.module.css +23 -0
  13. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  14. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  15. package/app/components/sidebar/files/files-modal.module.css +285 -44
  16. package/app/components/sidebar/files/files-modal.tsx +452 -145
  17. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  18. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  19. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  20. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  21. package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
  22. package/app/components/sidebar/notes/notes.module.css +236 -4
  23. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  24. package/app/components/sidebar/sidebar-container.tsx +1 -0
  25. package/app/components/sidebar/sidebar.tsx +12 -1
  26. package/app/hooks/useCaseListPreferences.ts +99 -0
  27. package/app/hooks/useFileListPreferences.ts +106 -0
  28. package/app/routes/striae/striae.tsx +1 -0
  29. package/app/types/annotations.ts +48 -1
  30. package/app/utils/data/case-filters.ts +127 -0
  31. package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
  32. package/app/utils/data/file-filters.ts +201 -0
  33. package/functions/api/image/[[path]].ts +4 -0
  34. package/package.json +3 -4
  35. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  36. package/workers/data-worker/wrangler.jsonc.example +1 -1
  37. package/workers/image-worker/wrangler.jsonc.example +1 -1
  38. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  39. package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
  40. package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
  41. package/workers/pdf-worker/src/report-layout.ts +227 -0
  42. package/workers/pdf-worker/src/report-types.ts +20 -0
  43. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  44. package/workers/user-worker/wrangler.jsonc.example +1 -1
  45. package/wrangler.toml.example +1 -1
  46. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  47. /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 { ensureCaseConfirmationSummary } 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 {
@@ -19,14 +33,48 @@ interface FilesModalProps {
19
33
  confirmationSaveVersion?: number;
20
34
  }
21
35
 
36
+ interface ActionNotice {
37
+ type: 'success' | 'warning' | 'error';
38
+ message: string;
39
+ }
40
+
22
41
  const FILES_PER_PAGE = 10;
23
42
 
24
- // Type to track confirmation status for each file
25
- interface FileConfirmationStatus {
26
- [fileId: string]: {
27
- includeConfirmation: boolean;
28
- isConfirmed: boolean;
29
- };
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';
30
78
  }
31
79
 
32
80
  export const FilesModal = ({
@@ -38,28 +86,82 @@ export const FilesModal = ({
38
86
  setFiles,
39
87
  isReadOnly = false,
40
88
  selectedFileId,
41
- confirmationSaveVersion = 0
89
+ confirmationSaveVersion = 0,
42
90
  }: FilesModalProps) => {
43
91
  const { user } = useContext(AuthContext);
44
- const [error, setError] = useState<string | null>(null);
45
92
  const [currentPage, setCurrentPage] = useState(0);
46
- const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
47
- 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();
48
116
  const {
49
117
  requestClose,
50
118
  overlayProps,
51
- getCloseButtonProps
119
+ getCloseButtonProps,
52
120
  } = useOverlayDismiss({
53
121
  isOpen,
54
- onClose
122
+ onClose,
55
123
  });
56
124
 
57
- const totalPages = Math.ceil(files.length / FILES_PER_PAGE);
58
- const startIndex = currentPage * FILES_PER_PAGE;
59
- const endIndex = startIndex + FILES_PER_PAGE;
60
- 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
+ );
61
164
 
62
- // Hydrate confirmation status from shared summary document.
63
165
  useEffect(() => {
64
166
  let isCancelled = false;
65
167
 
@@ -83,175 +185,380 @@ export const FilesModal = ({
83
185
  setFileConfirmationStatus(caseSummary.filesById);
84
186
  };
85
187
 
86
- fetchConfirmationStatuses();
188
+ void fetchConfirmationStatuses();
87
189
 
88
190
  return () => {
89
191
  isCancelled = true;
90
192
  };
91
193
  }, [isOpen, currentCase, files, user, confirmationSaveVersion]);
92
194
 
93
- const handleFileSelect = (file: FileData) => {
94
- onFileSelect?.(file);
95
- requestClose();
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
+ });
96
205
  };
97
206
 
98
- const handleDeleteFile = async (fileId: string, event: React.MouseEvent) => {
99
- event.stopPropagation(); // Prevent file selection when clicking delete
100
-
101
- // Don't allow file deletion for read-only cases
102
- if (isReadOnly) {
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());
219
+ };
220
+
221
+ const handleOpenSelectedFile = () => {
222
+ if (!openSelectedFileId) {
103
223
  return;
104
224
  }
105
-
106
- 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
+ });
107
232
  return;
108
233
  }
109
234
 
110
- setDeletingFileId(fileId);
111
-
112
- try {
113
- const deleteResult = await deleteFile(user, currentCase, fileId);
114
- // Remove the deleted file from the list
115
- const updatedFiles = files.filter(f => f.id !== fileId);
116
- setFiles(updatedFiles);
117
- setFileConfirmationStatus((previous) => {
118
- const next = { ...previous };
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);
262
+ }
263
+ }
264
+
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) => {
119
271
  delete next[fileId];
120
- return next;
121
272
  });
273
+ return next;
274
+ });
122
275
 
123
- if (deleteResult.imageMissing) {
124
- setError(`File record deleted. Image asset "${deleteResult.fileName}" was not found and was skipped.`);
125
- setTimeout(() => setError(null), 4000);
126
- }
127
-
128
- // Adjust page if needed
129
- const newTotalPages = Math.ceil(updatedFiles.length / FILES_PER_PAGE);
130
- if (currentPage >= newTotalPages && newTotalPages > 0) {
131
- setCurrentPage(newTotalPages - 1);
132
- }
133
- } catch (err) {
134
- console.error('Error deleting file:', err);
135
- setError('Failed to delete file');
136
- setTimeout(() => setError(null), 3000);
137
- } finally {
138
- setDeletingFileId(null);
276
+ setDeleteSelectedFileIds(new Set());
277
+ setIsDeleteModalOpen(false);
278
+
279
+ if (selectedFileId && deletedIds.includes(selectedFileId)) {
280
+ onFileSelect?.(CLEAR_SELECTION_FILE);
139
281
  }
140
- };
141
282
 
142
- const formatDate = (dateString: string) => {
143
- try {
144
- return new Date(dateString).toLocaleDateString();
145
- } catch {
146
- return 'Unknown';
283
+ if (effectiveOpenSelectedFileId && deletedIds.includes(effectiveOpenSelectedFileId)) {
284
+ setOpenSelectedFileId(null);
147
285
  }
148
- };
149
286
 
150
- const formatFileName = (filename: string) => {
151
- if (filename.length <= 30) return filename;
152
- const lastDotIndex = filename.lastIndexOf('.');
153
- if (lastDotIndex === -1) {
154
- 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
+ });
155
302
  }
156
- const name = filename.substring(0, lastDotIndex);
157
- const ext = filename.substring(lastDotIndex);
158
- const maxNameLength = 27 - ext.length;
159
- if (name.length <= maxNameLength) return filename;
160
- return name.substring(0, maxNameLength) + '…' + ext;
303
+
304
+ setIsDeletingSelected(false);
161
305
  };
162
306
 
163
- if (!isOpen) return null;
307
+ const canDeleteSelected = !isReadOnly && effectiveDeleteSelectedFileIds.size > 0 && !isDeletingSelected;
308
+
309
+ if (!isOpen) {
310
+ return null;
311
+ }
164
312
 
165
313
  return (
166
- <div
167
- className={styles.modalOverlay}
168
- aria-label="Close files dialog"
169
- {...overlayProps}
170
- >
314
+ <div className={styles.modalOverlay} aria-label="Close files dialog" {...overlayProps}>
171
315
  <div className={styles.modal}>
172
- <div className={styles.modalHeader}>
173
- <h2>Files in Case {currentCase}</h2>
316
+ <header className={styles.modalHeader}>
317
+ <h2>File Management {currentCase ? `- ${currentCase}` : ''}</h2>
174
318
  <button className={styles.closeButton} {...getCloseButtonProps({ ariaLabel: 'Close files dialog' })}>
175
319
  ×
176
320
  </button>
177
- </div>
178
-
321
+ </header>
322
+
179
323
  <div className={styles.modalContent}>
180
- {error ? (
181
- <div className={styles.errorState}>{error}</div>
182
- ) : files.length === 0 ? (
183
- <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>
184
326
  ) : (
185
- <div className={styles.filesList}>
186
- {currentFiles.map((file) => {
187
- const confirmationStatus = fileConfirmationStatus[file.id];
188
- let confirmationClass = '';
189
-
190
- if (confirmationStatus?.includeConfirmation) {
191
- confirmationClass = confirmationStatus.isConfirmed
192
- ? styles.fileItemConfirmed
193
- : styles.fileItemNotConfirmed;
194
- }
195
-
196
- return (
197
- <div
198
- key={file.id}
199
- className={`${styles.fileItem} ${selectedFileId === file.id ? styles.active : ''} ${confirmationClass}`}
200
- onClick={() => handleFileSelect(file)}
201
- onKeyDown={(event) => {
202
- if (event.key === 'Enter' || event.key === ' ') {
203
- event.preventDefault();
204
- handleFileSelect(file);
205
- }
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);
206
337
  }}
207
- role="button"
208
- tabIndex={0}
209
338
  >
210
- <div className={styles.fileInfo}>
211
- <div className={styles.fileName} title={file.originalFilename}>
212
- {formatFileName(file.originalFilename)}
213
- </div>
214
- <div className={styles.fileDate}>
215
- Uploaded: {formatDate(file.uploadedAt)}
216
- </div>
217
- </div>
218
- <button
219
- className={styles.deleteButton}
220
- onClick={(e) => handleDeleteFile(file.id, e)}
221
- disabled={isReadOnly || deletingFileId === file.id}
222
- aria-label={`Delete ${file.originalFilename}`}
223
- title={isReadOnly ? "Cannot delete files for read-only cases" : "Delete file"}
224
- style={{ opacity: isReadOnly ? 0.5 : 1, cursor: isReadOnly ? 'not-allowed' : 'pointer' }}
225
- >
226
- {deletingFileId === file.id ? '⏳' : '×'}
227
- </button>
228
- </div>
229
- );
230
- })}
231
- </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
+ </>
232
492
  )}
233
493
  </div>
234
-
235
- {totalPages > 1 && (
236
- <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>
237
505
  <button
238
- onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
239
- disabled={currentPage === 0}
506
+ type="button"
507
+ className={styles.secondaryActionButton}
508
+ onClick={clearDeleteSelection}
509
+ disabled={effectiveDeleteSelectedFileIds.size === 0 || isDeletingSelected}
240
510
  >
241
- Previous
511
+ Clear Selected
242
512
  </button>
243
- <span>
244
- Page {currentPage + 1} of {totalPages} ({files.length} total files)
245
- </span>
246
513
  <button
247
- onClick={() => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))}
248
- disabled={currentPage === totalPages - 1}
514
+ type="button"
515
+ className={`${styles.secondaryActionButton} ${styles.deleteActionButton}`}
516
+ onClick={() => setIsDeleteModalOpen(true)}
517
+ disabled={!canDeleteSelected}
249
518
  >
250
- Next
519
+ Delete Selected ({effectiveDeleteSelectedFileIds.size})
251
520
  </button>
252
521
  </div>
253
- )}
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>
254
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
+ />
255
562
  </div>
256
563
  );
257
564
  };