@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,8 +1,30 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type React from 'react';
2
3
  import type { User } from 'firebase/auth';
3
4
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
4
- import { listCases } from '~/components/actions/case-manage';
5
- import { getFileAnnotations } from '~/utils/data';
5
+ import { useCaseListPreferences, DEFAULT_CASES_MODAL_PREFERENCES } from '~/hooks/useCaseListPreferences';
6
+ import {
7
+ type CasesModalCaseItem,
8
+ type CasesModalSortBy,
9
+ type CasesModalConfirmationFilter,
10
+ getCasesForModal,
11
+ } from '~/utils/data/case-filters';
12
+ import {
13
+ archiveCase,
14
+ deleteCase,
15
+ renameCase,
16
+ validateCaseNumber,
17
+ } from '~/components/actions/case-manage';
18
+ import { RenameCaseModal } from '../../navbar/case-modals/rename-case-modal';
19
+ import { ArchiveCaseModal } from '../../navbar/case-modals/archive-case-modal';
20
+ import { DeleteCaseModal } from '../../navbar/case-modals/delete-case-modal';
21
+ import {
22
+ ensureCaseConfirmationSummary,
23
+ getCaseData,
24
+ getConfirmationSummaryDocument,
25
+ getUserCases,
26
+ getUserReadOnlyCases,
27
+ } from '~/utils/data';
6
28
  import { fetchFiles } from '~/components/actions/image-manage';
7
29
  import styles from './cases-modal.module.css';
8
30
 
@@ -12,13 +34,58 @@ interface CasesModalProps {
12
34
  onSelectCase: (caseNum: string) => void;
13
35
  currentCase: string;
14
36
  user: User;
37
+ confirmationSaveVersion?: number;
15
38
  }
16
39
 
17
- export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }: CasesModalProps) => {
18
- const [cases, setCases] = useState<string[]>([]);
40
+ interface CaseConfirmationStatus {
41
+ [caseNum: string]: { includeConfirmation: boolean; isConfirmed: boolean };
42
+ }
43
+
44
+ const CASES_PER_PAGE = 10;
45
+
46
+ const DEFAULT_CONFIRMATION_STATUS = {
47
+ includeConfirmation: false,
48
+ isConfirmed: false,
49
+ };
50
+
51
+ const getCaseUpdatedLabel = (createdAt: string): string => {
52
+ const parsed = Date.parse(createdAt);
53
+
54
+ if (Number.isNaN(parsed)) {
55
+ return 'Date unavailable';
56
+ }
57
+
58
+ return new Date(parsed).toLocaleDateString();
59
+ };
60
+
61
+ export const CasesModal = ({
62
+ isOpen,
63
+ onClose,
64
+ onSelectCase,
65
+ currentCase,
66
+ user,
67
+ confirmationSaveVersion = 0
68
+ }: CasesModalProps) => {
69
+ const [allCases, setAllCases] = useState<CasesModalCaseItem[]>([]);
19
70
  const [isLoading, setIsLoading] = useState(false);
71
+ const [isRunningAction, setIsRunningAction] = useState(false);
72
+ const [searchQuery, setSearchQuery] = useState('');
73
+ const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
74
+ const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
75
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
76
+ const [actionNotice, setActionNotice] = useState<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null);
20
77
  const [error, setError] = useState<string>('');
21
78
  const [currentPage, setCurrentPage] = useState(0);
79
+ const [selectedCaseNumber, setSelectedCaseNumber] = useState<string | null>(currentCase || null);
80
+ const [focusedIndex, setFocusedIndex] = useState(0);
81
+ const caseRowRefs = useRef<Array<HTMLDivElement | null>>([]);
82
+ const {
83
+ preferences,
84
+ setSortBy,
85
+ setConfirmationFilter,
86
+ setShowArchivedOnly,
87
+ resetPreferences,
88
+ } = useCaseListPreferences();
22
89
  const {
23
90
  requestClose,
24
91
  overlayProps,
@@ -27,10 +94,56 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
27
94
  isOpen,
28
95
  onClose
29
96
  });
30
- const [caseConfirmationStatus, setCaseConfirmationStatus] = useState<{
31
- [caseNum: string]: { includeConfirmation: boolean; isConfirmed: boolean }
32
- }>({});
33
- const CASES_PER_PAGE = 10;
97
+ const [caseConfirmationStatus, setCaseConfirmationStatus] = useState<CaseConfirmationStatus>({});
98
+ const caseConfirmationStatusRef = useRef<CaseConfirmationStatus>({});
99
+
100
+ const loadCases = useCallback(async () => {
101
+ try {
102
+ const [ownedCases, readOnlyCases] = await Promise.all([
103
+ getUserCases(user),
104
+ getUserReadOnlyCases(user),
105
+ ]);
106
+
107
+ const ownedCaseEntries = await Promise.all(
108
+ ownedCases.map(async (entry) => {
109
+ const caseData = await getCaseData(user, entry.caseNumber).catch(() => null);
110
+
111
+ return {
112
+ caseNumber: entry.caseNumber,
113
+ createdAt: entry.createdAt,
114
+ archived: caseData?.archived === true,
115
+ isReadOnly: false,
116
+ } as CasesModalCaseItem;
117
+ })
118
+ );
119
+
120
+ const readOnlyEntries: CasesModalCaseItem[] = readOnlyCases.map((entry) => ({
121
+ caseNumber: entry.caseNumber,
122
+ createdAt: entry.importedAt,
123
+ archived: false,
124
+ isReadOnly: true,
125
+ }));
126
+
127
+ const mergedCasesMap = new Map<string, CasesModalCaseItem>();
128
+ [...ownedCaseEntries, ...readOnlyEntries].forEach((entry) => {
129
+ if (!mergedCasesMap.has(entry.caseNumber)) {
130
+ mergedCasesMap.set(entry.caseNumber, entry);
131
+ }
132
+ });
133
+
134
+ setAllCases(Array.from(mergedCasesMap.values()));
135
+ setSelectedCaseNumber((previous) => previous ?? (currentCase || null));
136
+ } catch (err) {
137
+ console.error('Failed to load cases:', err);
138
+ setError('Failed to load cases');
139
+ } finally {
140
+ setIsLoading(false);
141
+ }
142
+ }, [user, currentCase]);
143
+
144
+ useEffect(() => {
145
+ caseConfirmationStatusRef.current = caseConfirmationStatus;
146
+ }, [caseConfirmationStatus]);
34
147
 
35
148
  const startLoading = () => {
36
149
  setIsLoading(true);
@@ -42,100 +155,419 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
42
155
  const loadingTimer = window.setTimeout(() => {
43
156
  startLoading();
44
157
  }, 0);
45
-
46
- listCases(user)
47
- .then(fetchedCases => {
48
- setCases(fetchedCases);
49
- })
50
- .catch(err => {
51
- console.error('Failed to load cases:', err);
52
- setError('Failed to load cases');
53
- })
54
- .finally(() => {
55
- setIsLoading(false);
56
- });
158
+
159
+ void loadCases();
57
160
 
58
161
  return () => {
59
162
  window.clearTimeout(loadingTimer);
60
163
  };
61
164
  }
62
- }, [isOpen, user]);
165
+ }, [isOpen, loadCases]);
166
+
167
+ const archiveScopedCases = useMemo(() => {
168
+ if (preferences.showArchivedOnly) {
169
+ return allCases.filter((entry) => entry.archived && !entry.isReadOnly);
170
+ }
171
+
172
+ return allCases.filter((entry) => !entry.archived && !entry.isReadOnly);
173
+ }, [allCases, preferences.showArchivedOnly]);
174
+
175
+ const visibleCases = useMemo(() => {
176
+ const baseCases = getCasesForModal(allCases, preferences, caseConfirmationStatus);
177
+ const normalizedQuery = searchQuery.trim().toLowerCase();
178
+
179
+ if (!normalizedQuery) {
180
+ return baseCases;
181
+ }
182
+
183
+ return baseCases.filter((entry) =>
184
+ entry.caseNumber.toLowerCase().includes(normalizedQuery)
185
+ );
186
+ }, [allCases, preferences, caseConfirmationStatus, searchQuery]);
187
+
188
+ const totalPages = Math.max(1, Math.ceil(visibleCases.length / CASES_PER_PAGE));
189
+
190
+ useEffect(() => {
191
+ if (currentPage > totalPages - 1) {
192
+ setCurrentPage(totalPages - 1);
193
+ }
194
+ }, [currentPage, totalPages]);
63
195
 
64
- const paginatedCases = cases.slice(
196
+ const paginatedCases = visibleCases.slice(
65
197
  currentPage * CASES_PER_PAGE,
66
198
  (currentPage + 1) * CASES_PER_PAGE
67
199
  );
68
200
 
69
- const totalPages = Math.ceil(cases.length / CASES_PER_PAGE);
201
+ const hasCustomPreferences =
202
+ preferences.sortBy !== DEFAULT_CASES_MODAL_PREFERENCES.sortBy ||
203
+ preferences.confirmationFilter !== DEFAULT_CASES_MODAL_PREFERENCES.confirmationFilter ||
204
+ preferences.showArchivedOnly !== DEFAULT_CASES_MODAL_PREFERENCES.showArchivedOnly;
205
+
206
+ const selectedCase = useMemo(
207
+ () => allCases.find((entry) => entry.caseNumber === selectedCaseNumber) ?? null,
208
+ [allCases, selectedCaseNumber]
209
+ );
210
+
211
+ const canRenameSelectedCase = Boolean(
212
+ selectedCase && !selectedCase.archived && !selectedCase.isReadOnly
213
+ );
214
+
215
+ const canArchiveSelectedCase = Boolean(
216
+ selectedCase && !selectedCase.archived && !selectedCase.isReadOnly
217
+ );
218
+
219
+ const canDeleteSelectedCase = Boolean(
220
+ selectedCase && selectedCase.caseNumber !== currentCase && !selectedCase.isReadOnly
221
+ );
70
222
 
71
- // Fetch confirmation status only for currently visible paginated cases
72
223
  useEffect(() => {
73
- const fetchCaseConfirmationStatuses = async () => {
74
- const visibleCases = cases.slice(
75
- currentPage * CASES_PER_PAGE,
76
- (currentPage + 1) * CASES_PER_PAGE
77
- );
224
+ setCurrentPage(0);
225
+ }, [preferences.sortBy, preferences.confirmationFilter, preferences.showArchivedOnly]);
78
226
 
79
- if (!isOpen || visibleCases.length === 0) {
80
- return;
81
- }
227
+ useEffect(() => {
228
+ if (paginatedCases.length === 0) {
229
+ setFocusedIndex(0);
230
+ return;
231
+ }
82
232
 
83
- // Fetch case statuses in parallel for only visible cases
84
- const caseStatusPromises = visibleCases.map(async (caseNum) => {
85
- try {
86
- const files = await fetchFiles(user, caseNum, { skipValidation: true });
87
-
88
- // Fetch annotations for each file in the case (in parallel)
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);
233
+ if (focusedIndex > paginatedCases.length - 1) {
234
+ setFocusedIndex(paginatedCases.length - 1);
235
+ }
236
+ }, [paginatedCases, focusedIndex]);
106
237
 
107
- return {
108
- caseNum,
109
- includeConfirmation: filesRequiringConfirmation.length > 0,
110
- isConfirmed: filesRequiringConfirmation.length > 0 ? allConfirmedFiles : false,
111
- };
112
- } catch (err) {
113
- console.error(`Error fetching confirmation status for case ${caseNum}:`, err);
114
- return {
115
- caseNum,
116
- includeConfirmation: false,
117
- isConfirmed: false,
118
- };
119
- }
120
- });
238
+ useEffect(() => {
239
+ if (!selectedCaseNumber) {
240
+ return;
241
+ }
242
+
243
+ const exists = allCases.some((entry) => entry.caseNumber === selectedCaseNumber);
244
+ if (!exists) {
245
+ setSelectedCaseNumber(null);
246
+ }
247
+ }, [allCases, selectedCaseNumber]);
248
+
249
+ const hydrateCaseConfirmationStatuses = useCallback(async (caseNumbers: string[]) => {
250
+ const missingCaseNumbers = caseNumbers.filter(
251
+ (caseNum) => !caseConfirmationStatusRef.current[caseNum]
252
+ );
121
253
 
122
- // Wait for all case status fetches to complete
123
- const results = await Promise.all(caseStatusPromises);
254
+ if (missingCaseNumbers.length === 0) {
255
+ return;
256
+ }
257
+
258
+ const caseStatusPromises = missingCaseNumbers.map(async (caseNum) => {
259
+ try {
260
+ const files = await fetchFiles(user, caseNum);
261
+ const caseSummary = await ensureCaseConfirmationSummary(user, caseNum, files);
262
+
263
+ return {
264
+ caseNum,
265
+ includeConfirmation: caseSummary.includeConfirmation,
266
+ isConfirmed: caseSummary.isConfirmed,
267
+ };
268
+ } catch (err) {
269
+ console.error(`Error fetching confirmation status for case ${caseNum}:`, err);
270
+ return {
271
+ caseNum,
272
+ includeConfirmation: false,
273
+ isConfirmed: false,
274
+ };
275
+ }
276
+ });
277
+
278
+ const results = await Promise.all(caseStatusPromises);
279
+
280
+ setCaseConfirmationStatus((previous) => {
281
+ const next = { ...previous };
124
282
 
125
- // Build the statuses map from results
126
- const statuses: { [caseNum: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
127
283
  results.forEach((result) => {
128
- statuses[result.caseNum] = {
284
+ next[result.caseNum] = {
129
285
  includeConfirmation: result.includeConfirmation,
130
286
  isConfirmed: result.isConfirmed,
131
287
  };
132
288
  });
133
289
 
290
+ return next;
291
+ });
292
+ }, [user]);
293
+
294
+ // Fetch confirmation status only for currently visible paginated cases
295
+ useEffect(() => {
296
+ let isCancelled = false;
297
+
298
+ const loadConfirmationSummary = async () => {
299
+ if (!isOpen) {
300
+ return;
301
+ }
302
+
303
+ const summary = await getConfirmationSummaryDocument(user).catch((err) => {
304
+ console.error('Failed to load confirmation summary:', err);
305
+ return null;
306
+ });
307
+
308
+ if (!summary || isCancelled) {
309
+ return;
310
+ }
311
+
312
+ const statuses: CaseConfirmationStatus = {};
313
+ for (const [caseNum, entry] of Object.entries(summary.cases)) {
314
+ statuses[caseNum] = {
315
+ includeConfirmation: entry.includeConfirmation,
316
+ isConfirmed: entry.isConfirmed
317
+ };
318
+ }
319
+
134
320
  setCaseConfirmationStatus(statuses);
135
321
  };
136
322
 
137
- fetchCaseConfirmationStatuses();
138
- }, [isOpen, currentPage, cases, user]);
323
+ loadConfirmationSummary();
324
+
325
+ return () => {
326
+ isCancelled = true;
327
+ };
328
+ }, [isOpen, user, confirmationSaveVersion]);
329
+
330
+ useEffect(() => {
331
+ if (!isOpen || paginatedCases.length === 0) {
332
+ return;
333
+ }
334
+
335
+ void hydrateCaseConfirmationStatuses(paginatedCases.map((entry) => entry.caseNumber));
336
+ }, [isOpen, paginatedCases, hydrateCaseConfirmationStatuses]);
337
+
338
+ useEffect(() => {
339
+ if (!isOpen || preferences.confirmationFilter === 'all' || archiveScopedCases.length === 0) {
340
+ return;
341
+ }
342
+
343
+ void hydrateCaseConfirmationStatuses(archiveScopedCases.map((entry) => entry.caseNumber));
344
+ }, [
345
+ isOpen,
346
+ preferences.confirmationFilter,
347
+ archiveScopedCases,
348
+ hydrateCaseConfirmationStatuses,
349
+ ]);
350
+
351
+ const handleSelectCase = (caseNum: string, index: number) => {
352
+ setSelectedCaseNumber(caseNum);
353
+ setFocusedIndex(index);
354
+ };
355
+
356
+ const handleOpenSelectedCase = () => {
357
+ if (!selectedCaseNumber) {
358
+ return;
359
+ }
360
+
361
+ onSelectCase(selectedCaseNumber);
362
+ requestClose();
363
+ };
364
+
365
+ const handleRenameSelectedCase = async () => {
366
+ if (!selectedCase || !canRenameSelectedCase) {
367
+ setActionNotice({
368
+ type: 'warning',
369
+ message: 'Selected case cannot be renamed.',
370
+ });
371
+ return;
372
+ }
373
+
374
+ setActionNotice(null);
375
+ setIsRenameModalOpen(true);
376
+ };
377
+
378
+ const handleRenameSelectedCaseSubmit = async (nextCaseName: string) => {
379
+ if (!selectedCase) {
380
+ setActionNotice({
381
+ type: 'error',
382
+ message: 'No selected case to rename.',
383
+ });
384
+ return;
385
+ }
386
+
387
+ const nextCaseNumber = nextCaseName.trim();
388
+ if (!nextCaseNumber) {
389
+ setActionNotice({
390
+ type: 'error',
391
+ message: 'Provide a new case number.',
392
+ });
393
+ return;
394
+ }
395
+
396
+ if (!validateCaseNumber(nextCaseNumber)) {
397
+ setActionNotice({
398
+ type: 'error',
399
+ message: 'Invalid case number format.',
400
+ });
401
+ return;
402
+ }
403
+
404
+ setIsRunningAction(true);
405
+ setActionNotice(null);
406
+
407
+ try {
408
+ await renameCase(user, selectedCase.caseNumber, nextCaseNumber);
409
+ await loadCases();
410
+ setSelectedCaseNumber(nextCaseNumber);
411
+ setIsRenameModalOpen(false);
412
+
413
+ if (selectedCase.caseNumber === currentCase) {
414
+ onSelectCase(nextCaseNumber);
415
+ }
416
+
417
+ setActionNotice({
418
+ type: 'success',
419
+ message: `Case renamed to ${nextCaseNumber}.`,
420
+ });
421
+ } catch (renameError) {
422
+ setActionNotice({
423
+ type: 'error',
424
+ message: renameError instanceof Error ? renameError.message : 'Failed to rename case.',
425
+ });
426
+ } finally {
427
+ setIsRunningAction(false);
428
+ }
429
+ };
430
+
431
+ const handleArchiveSelectedCase = async () => {
432
+ if (!selectedCase || !canArchiveSelectedCase) {
433
+ setActionNotice({
434
+ type: 'warning',
435
+ message: 'Selected case cannot be archived.',
436
+ });
437
+ return;
438
+ }
439
+
440
+ setActionNotice(null);
441
+ setIsArchiveModalOpen(true);
442
+ };
443
+
444
+ const handleArchiveSelectedCaseSubmit = async (archiveReason: string) => {
445
+ if (!selectedCase) {
446
+ setActionNotice({
447
+ type: 'error',
448
+ message: 'No selected case to archive.',
449
+ });
450
+ return;
451
+ }
452
+
453
+ setIsRunningAction(true);
454
+ setActionNotice(null);
455
+
456
+ try {
457
+ await archiveCase(user, selectedCase.caseNumber, archiveReason);
458
+ await loadCases();
459
+ setIsArchiveModalOpen(false);
460
+
461
+ if (selectedCase.caseNumber === currentCase) {
462
+ onSelectCase(selectedCase.caseNumber);
463
+ }
464
+
465
+ setActionNotice({
466
+ type: 'success',
467
+ message: 'Case archived successfully.',
468
+ });
469
+ } catch (archiveError) {
470
+ setActionNotice({
471
+ type: 'error',
472
+ message: archiveError instanceof Error ? archiveError.message : 'Failed to archive case.',
473
+ });
474
+ } finally {
475
+ setIsRunningAction(false);
476
+ }
477
+ };
478
+
479
+ const handleDeleteSelectedCase = async () => {
480
+ if (!selectedCase || !canDeleteSelectedCase) {
481
+ const isCurrentCaseSelection = selectedCase?.caseNumber === currentCase;
482
+
483
+ setActionNotice({
484
+ type: 'warning',
485
+ message: isCurrentCaseSelection
486
+ ? 'Open a different case before deleting this one.'
487
+ : 'Selected case cannot be deleted.',
488
+ });
489
+ return;
490
+ }
491
+
492
+ setActionNotice(null);
493
+ setIsDeleteModalOpen(true);
494
+ };
495
+
496
+ const handleDeleteSelectedCaseSubmit = async () => {
497
+ if (!selectedCase) {
498
+ setActionNotice({
499
+ type: 'error',
500
+ message: 'No selected case to delete.',
501
+ });
502
+ return;
503
+ }
504
+
505
+ setIsRunningAction(true);
506
+ setActionNotice(null);
507
+
508
+ try {
509
+ const deleteResult = await deleteCase(user, selectedCase.caseNumber);
510
+ await loadCases();
511
+ setSelectedCaseNumber(null);
512
+ setIsDeleteModalOpen(false);
513
+
514
+ if (deleteResult.missingImages.length > 0) {
515
+ setActionNotice({
516
+ type: 'warning',
517
+ message: `Case deleted. ${deleteResult.missingImages.length} image(s) were missing and skipped.`,
518
+ });
519
+ } else {
520
+ setActionNotice({
521
+ type: 'success',
522
+ message: 'Case deleted successfully.',
523
+ });
524
+ }
525
+ } catch (deleteError) {
526
+ setActionNotice({
527
+ type: 'error',
528
+ message: deleteError instanceof Error ? deleteError.message : 'Failed to delete case.',
529
+ });
530
+ } finally {
531
+ setIsRunningAction(false);
532
+ }
533
+ };
534
+
535
+ const handleRowKeyDown = (
536
+ event: React.KeyboardEvent<HTMLDivElement>,
537
+ caseNum: string,
538
+ index: number
539
+ ) => {
540
+ if (event.key === 'ArrowDown') {
541
+ event.preventDefault();
542
+ const nextIndex = Math.min(index + 1, paginatedCases.length - 1);
543
+ setFocusedIndex(nextIndex);
544
+ window.requestAnimationFrame(() => {
545
+ caseRowRefs.current[nextIndex]?.focus();
546
+ });
547
+ return;
548
+ }
549
+
550
+ if (event.key === 'ArrowUp') {
551
+ event.preventDefault();
552
+ const nextIndex = Math.max(index - 1, 0);
553
+ setFocusedIndex(nextIndex);
554
+ window.requestAnimationFrame(() => {
555
+ caseRowRefs.current[nextIndex]?.focus();
556
+ });
557
+ return;
558
+ }
559
+
560
+ if (event.key === 'Enter' || event.key === ' ') {
561
+ event.preventDefault();
562
+ handleSelectCase(caseNum, index);
563
+ return;
564
+ }
565
+
566
+ if (event.key === 'Escape') {
567
+ event.preventDefault();
568
+ requestClose();
569
+ }
570
+ };
139
571
 
140
572
  if (!isOpen) return null;
141
573
 
@@ -150,62 +582,251 @@ export const CasesModal = ({ isOpen, onClose, onSelectCase, currentCase, user }:
150
582
  <h2>All Cases</h2>
151
583
  <button className={styles.closeButton} {...getCloseButtonProps({ ariaLabel: 'Close cases dialog' })}>&times;</button>
152
584
  </header>
153
-
585
+
154
586
  <div className={styles.modalContent}>
155
587
  {isLoading ? (
156
588
  <p className={styles.loading}>Loading cases...</p>
157
589
  ) : error ? (
158
590
  <p className={styles.error}>{error}</p>
159
- ) : cases.length === 0 ? (
591
+ ) : allCases.length === 0 ? (
160
592
  <p className={styles.emptyState}>No cases found</p>
161
593
  ) : (
162
- <ul className={styles.casesList}>
163
- {paginatedCases.map((caseNum) => {
164
- const confirmationStatus = caseConfirmationStatus[caseNum];
165
- let confirmationClass = '';
166
-
167
- if (confirmationStatus?.includeConfirmation) {
168
- confirmationClass = confirmationStatus.isConfirmed
169
- ? styles.caseItemConfirmed
170
- : styles.caseItemNotConfirmed;
171
- }
172
-
173
- return (
174
- <li key={caseNum}>
175
- <button
176
- className={`${styles.caseItem} ${currentCase === caseNum ? styles.active : ''} ${confirmationClass}`}
177
- onClick={() => {
178
- onSelectCase(caseNum);
179
- requestClose();
180
- }}
181
- >
182
- {caseNum}
183
- </button>
184
- </li>
185
- );
186
- })}
187
- </ul>
594
+ <>
595
+ <section className={styles.controlsSection} aria-label="Case list controls">
596
+ <div className={styles.controlGroup}>
597
+ <label htmlFor="cases-sort">Sort</label>
598
+ <select
599
+ id="cases-sort"
600
+ value={preferences.sortBy}
601
+ onChange={(event) => setSortBy(event.target.value as CasesModalSortBy)}
602
+ >
603
+ <option value="recent">Most Recent</option>
604
+ <option value="alphabetical">Numerical/Alphabetical</option>
605
+ </select>
606
+ </div>
607
+
608
+ <div className={styles.controlGroup}>
609
+ <label htmlFor="cases-confirmation-filter">Confirmation Status</label>
610
+ <select
611
+ id="cases-confirmation-filter"
612
+ value={preferences.confirmationFilter}
613
+ onChange={(event) =>
614
+ setConfirmationFilter(event.target.value as CasesModalConfirmationFilter)
615
+ }
616
+ >
617
+ <option value="all">All Cases</option>
618
+ <option value="pending">Pending Confirmation</option>
619
+ <option value="confirmed">Confirmed</option>
620
+ <option value="none-requested">None Requested</option>
621
+ </select>
622
+ </div>
623
+
624
+ <label className={styles.archiveToggle}>
625
+ <input
626
+ type="checkbox"
627
+ checked={preferences.showArchivedOnly}
628
+ onChange={(event) => setShowArchivedOnly(event.target.checked)}
629
+ />
630
+ Archived only
631
+ </label>
632
+
633
+ <button
634
+ type="button"
635
+ className={styles.resetButton}
636
+ onClick={resetPreferences}
637
+ disabled={!hasCustomPreferences && searchQuery.trim().length === 0}
638
+ >
639
+ Reset
640
+ </button>
641
+ </section>
642
+
643
+ <div className={styles.searchSection}>
644
+ <label htmlFor="case-search">Search case number</label>
645
+ <input
646
+ id="case-search"
647
+ type="text"
648
+ value={searchQuery}
649
+ onChange={(event) => setSearchQuery(event.target.value)}
650
+ placeholder="Type to filter case numbers"
651
+ className={styles.searchInput}
652
+ />
653
+ </div>
654
+
655
+ <p className={styles.caseCount}>
656
+ {visibleCases.length} shown of {allCases.length} total cases
657
+ </p>
658
+
659
+ {actionNotice && (
660
+ <p
661
+ className={`${styles.actionNotice} ${
662
+ actionNotice.type === 'error'
663
+ ? styles.actionNoticeError
664
+ : actionNotice.type === 'warning'
665
+ ? styles.actionNoticeWarning
666
+ : styles.actionNoticeSuccess
667
+ }`}
668
+ >
669
+ {actionNotice.message}
670
+ </p>
671
+ )}
672
+
673
+ {visibleCases.length === 0 ? (
674
+ <p className={styles.emptyState}>No cases match your filters</p>
675
+ ) : (
676
+ <ul className={styles.casesList} role="listbox" aria-label="Cases list">
677
+ {paginatedCases.map((caseEntry, index) => {
678
+ const caseNum = caseEntry.caseNumber;
679
+ const confirmationStatus = caseConfirmationStatus[caseNum] || DEFAULT_CONFIRMATION_STATUS;
680
+ const isSelected = selectedCaseNumber === caseNum;
681
+ const confirmationLabel = confirmationStatus.includeConfirmation
682
+ ? confirmationStatus.isConfirmed
683
+ ? 'Confirmed'
684
+ : 'Pending'
685
+ : 'None Requested';
686
+
687
+ let confirmationClass = '';
688
+
689
+ if (confirmationStatus.includeConfirmation) {
690
+ confirmationClass = confirmationStatus.isConfirmed
691
+ ? styles.caseItemConfirmed
692
+ : styles.caseItemNotConfirmed;
693
+ }
694
+
695
+ return (
696
+ <li key={caseNum}>
697
+ <div
698
+ ref={(node) => {
699
+ caseRowRefs.current[index] = node;
700
+ }}
701
+ role="option"
702
+ aria-selected={isSelected}
703
+ tabIndex={focusedIndex === index ? 0 : -1}
704
+ className={`${styles.caseItem} ${isSelected ? styles.active : ''}`}
705
+ onClick={() => handleSelectCase(caseNum, index)}
706
+ onFocus={() => setFocusedIndex(index)}
707
+ onKeyDown={(event) => handleRowKeyDown(event, caseNum, index)}
708
+ >
709
+ <div className={styles.caseDetails}>
710
+ <input
711
+ type="text"
712
+ readOnly
713
+ value={caseNum}
714
+ className={styles.caseNumberInput}
715
+ aria-label={`Case number ${caseNum}`}
716
+ onClick={(event) => event.stopPropagation()}
717
+ />
718
+ <span className={styles.caseMetaText}>
719
+ Created: {getCaseUpdatedLabel(caseEntry.createdAt)}
720
+ </span>
721
+ </div>
722
+
723
+ <span
724
+ className={`${styles.confirmationBadge} ${confirmationClass}`}
725
+ aria-label={`Confirmation status: ${confirmationLabel}`}
726
+ >
727
+ {confirmationLabel}
728
+ </span>
729
+ </div>
730
+ </li>
731
+ );
732
+ })}
733
+ </ul>
734
+ )}
735
+ </>
188
736
  )}
189
737
  </div>
190
-
191
- {totalPages > 1 && (
192
- <div className={styles.pagination}>
738
+
739
+ <div className={styles.footerActions}>
740
+ <div className={styles.maintenanceActions}>
741
+ <button
742
+ type="button"
743
+ className={styles.secondaryActionButton}
744
+ onClick={() => {
745
+ setSearchQuery('');
746
+ resetPreferences();
747
+ }}
748
+ disabled={isRunningAction}
749
+ >
750
+ Clear Filters
751
+ </button>
752
+ <button
753
+ type="button"
754
+ className={`${styles.secondaryActionButton} ${styles.renameActionButton}`}
755
+ onClick={handleRenameSelectedCase}
756
+ disabled={!canRenameSelectedCase || isRunningAction}
757
+ >
758
+ Rename Selected
759
+ </button>
193
760
  <button
194
- onClick={() => setCurrentPage(p => p - 1)}
195
- disabled={currentPage === 0}
761
+ type="button"
762
+ className={`${styles.secondaryActionButton} ${styles.archiveActionButton}`}
763
+ onClick={handleArchiveSelectedCase}
764
+ disabled={!canArchiveSelectedCase || isRunningAction}
196
765
  >
197
- Previous
766
+ Archive Selected
198
767
  </button>
199
- <span>{currentPage + 1} of {totalPages} ({cases.length} total cases)</span>
200
768
  <button
201
- onClick={() => setCurrentPage(p => p + 1)}
202
- disabled={currentPage === totalPages - 1}
769
+ type="button"
770
+ className={`${styles.secondaryActionButton} ${styles.deleteActionButton}`}
771
+ onClick={handleDeleteSelectedCase}
772
+ disabled={!canDeleteSelectedCase || isRunningAction}
203
773
  >
204
- Next
774
+ Delete Selected
205
775
  </button>
206
776
  </div>
207
- )}
777
+
778
+ <button
779
+ type="button"
780
+ className={styles.openSelectedButton}
781
+ onClick={handleOpenSelectedCase}
782
+ disabled={!selectedCaseNumber || isRunningAction}
783
+ >
784
+ {isRunningAction ? 'Working...' : 'Open Selected Case'}
785
+ </button>
786
+
787
+ {totalPages > 1 && (
788
+ <div className={styles.pagination}>
789
+ <button
790
+ onClick={() => setCurrentPage(p => p - 1)}
791
+ disabled={currentPage === 0}
792
+ >
793
+ Previous
794
+ </button>
795
+ <span>{currentPage + 1} of {totalPages} ({visibleCases.length} filtered cases)</span>
796
+ <button
797
+ onClick={() => setCurrentPage(p => p + 1)}
798
+ disabled={currentPage === totalPages - 1}
799
+ >
800
+ Next
801
+ </button>
802
+ </div>
803
+ )}
804
+ </div>
208
805
  </div>
806
+
807
+ <RenameCaseModal
808
+ isOpen={isRenameModalOpen}
809
+ currentCase={selectedCase?.caseNumber || ''}
810
+ isSubmitting={isRunningAction}
811
+ onClose={() => setIsRenameModalOpen(false)}
812
+ onSubmit={handleRenameSelectedCaseSubmit}
813
+ />
814
+
815
+ <ArchiveCaseModal
816
+ isOpen={isArchiveModalOpen}
817
+ currentCase={selectedCase?.caseNumber || ''}
818
+ isSubmitting={isRunningAction}
819
+ onClose={() => setIsArchiveModalOpen(false)}
820
+ onSubmit={handleArchiveSelectedCaseSubmit}
821
+ />
822
+
823
+ <DeleteCaseModal
824
+ isOpen={isDeleteModalOpen}
825
+ currentCase={selectedCase?.caseNumber || ''}
826
+ isSubmitting={isRunningAction}
827
+ onClose={() => setIsDeleteModalOpen(false)}
828
+ onSubmit={handleDeleteSelectedCaseSubmit}
829
+ />
209
830
  </div>
210
831
  );
211
832
  };