@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,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 { ensureCaseConfirmationSummary, getConfirmationSummaryDocument } 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
 
@@ -15,6 +37,27 @@ interface CasesModalProps {
15
37
  confirmationSaveVersion?: number;
16
38
  }
17
39
 
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
+
18
61
  export const CasesModal = ({
19
62
  isOpen,
20
63
  onClose,
@@ -23,10 +66,26 @@ export const CasesModal = ({
23
66
  user,
24
67
  confirmationSaveVersion = 0
25
68
  }: CasesModalProps) => {
26
- const [cases, setCases] = useState<string[]>([]);
69
+ const [allCases, setAllCases] = useState<CasesModalCaseItem[]>([]);
27
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);
28
77
  const [error, setError] = useState<string>('');
29
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();
30
89
  const {
31
90
  requestClose,
32
91
  overlayProps,
@@ -35,10 +94,56 @@ export const CasesModal = ({
35
94
  isOpen,
36
95
  onClose
37
96
  });
38
- const [caseConfirmationStatus, setCaseConfirmationStatus] = useState<{
39
- [caseNum: string]: { includeConfirmation: boolean; isConfirmed: boolean }
40
- }>({});
41
- 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]);
42
147
 
43
148
  const startLoading = () => {
44
149
  setIsLoading(true);
@@ -50,31 +155,141 @@ export const CasesModal = ({
50
155
  const loadingTimer = window.setTimeout(() => {
51
156
  startLoading();
52
157
  }, 0);
53
-
54
- listCases(user)
55
- .then(fetchedCases => {
56
- setCases(fetchedCases);
57
- })
58
- .catch(err => {
59
- console.error('Failed to load cases:', err);
60
- setError('Failed to load cases');
61
- })
62
- .finally(() => {
63
- setIsLoading(false);
64
- });
158
+
159
+ void loadCases();
65
160
 
66
161
  return () => {
67
162
  window.clearTimeout(loadingTimer);
68
163
  };
69
164
  }
70
- }, [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]);
71
195
 
72
- const paginatedCases = cases.slice(
196
+ const paginatedCases = visibleCases.slice(
73
197
  currentPage * CASES_PER_PAGE,
74
198
  (currentPage + 1) * CASES_PER_PAGE
75
199
  );
76
200
 
77
- 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
+ );
222
+
223
+ useEffect(() => {
224
+ setCurrentPage(0);
225
+ }, [preferences.sortBy, preferences.confirmationFilter, preferences.showArchivedOnly]);
226
+
227
+ useEffect(() => {
228
+ if (paginatedCases.length === 0) {
229
+ setFocusedIndex(0);
230
+ return;
231
+ }
232
+
233
+ if (focusedIndex > paginatedCases.length - 1) {
234
+ setFocusedIndex(paginatedCases.length - 1);
235
+ }
236
+ }, [paginatedCases, focusedIndex]);
237
+
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
+ );
253
+
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 };
282
+
283
+ results.forEach((result) => {
284
+ next[result.caseNum] = {
285
+ includeConfirmation: result.includeConfirmation,
286
+ isConfirmed: result.isConfirmed,
287
+ };
288
+ });
289
+
290
+ return next;
291
+ });
292
+ }, [user]);
78
293
 
79
294
  // Fetch confirmation status only for currently visible paginated cases
80
295
  useEffect(() => {
@@ -94,7 +309,7 @@ export const CasesModal = ({
94
309
  return;
95
310
  }
96
311
 
97
- const statuses: { [caseNum: string]: { includeConfirmation: boolean; isConfirmed: boolean } } = {};
312
+ const statuses: CaseConfirmationStatus = {};
98
313
  for (const [caseNum, entry] of Object.entries(summary.cases)) {
99
314
  statuses[caseNum] = {
100
315
  includeConfirmation: entry.includeConfirmation,
@@ -113,70 +328,246 @@ export const CasesModal = ({
113
328
  }, [isOpen, user, confirmationSaveVersion]);
114
329
 
115
330
  useEffect(() => {
116
- let isCancelled = false;
331
+ if (!isOpen || paginatedCases.length === 0) {
332
+ return;
333
+ }
117
334
 
118
- const fetchCaseConfirmationStatuses = async () => {
119
- const visibleCases = cases.slice(
120
- currentPage * CASES_PER_PAGE,
121
- (currentPage + 1) * CASES_PER_PAGE
122
- );
335
+ void hydrateCaseConfirmationStatuses(paginatedCases.map((entry) => entry.caseNumber));
336
+ }, [isOpen, paginatedCases, hydrateCaseConfirmationStatuses]);
123
337
 
124
- if (!isOpen || visibleCases.length === 0) {
125
- return;
126
- }
338
+ useEffect(() => {
339
+ if (!isOpen || preferences.confirmationFilter === 'all' || archiveScopedCases.length === 0) {
340
+ return;
341
+ }
127
342
 
128
- const missingCaseNumbers = visibleCases.filter((caseNum) => !caseConfirmationStatus[caseNum]);
129
- if (missingCaseNumbers.length === 0) {
130
- return;
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);
131
415
  }
132
416
 
133
- const caseStatusPromises = missingCaseNumbers.map(async (caseNum) => {
134
- try {
135
- const files = await fetchFiles(user, caseNum, { skipValidation: true });
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
+ };
136
430
 
137
- const caseSummary = await ensureCaseConfirmationSummary(user, caseNum, files);
431
+ const handleArchiveSelectedCase = async () => {
432
+ if (!selectedCase || !canArchiveSelectedCase) {
433
+ setActionNotice({
434
+ type: 'warning',
435
+ message: 'Selected case cannot be archived.',
436
+ });
437
+ return;
438
+ }
138
439
 
139
- return {
140
- caseNum,
141
- includeConfirmation: caseSummary.includeConfirmation,
142
- isConfirmed: caseSummary.isConfirmed,
143
- };
144
- } catch (err) {
145
- console.error(`Error fetching confirmation status for case ${caseNum}:`, err);
146
- return {
147
- caseNum,
148
- includeConfirmation: false,
149
- isConfirmed: false,
150
- };
151
- }
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.',
152
449
  });
450
+ return;
451
+ }
153
452
 
154
- // Wait for all case status fetches to complete
155
- const results = await Promise.all(caseStatusPromises);
453
+ setIsRunningAction(true);
454
+ setActionNotice(null);
156
455
 
157
- if (isCancelled) {
158
- return;
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);
159
463
  }
160
464
 
161
- setCaseConfirmationStatus((previous) => {
162
- const next = { ...previous };
163
- results.forEach((result) => {
164
- next[result.caseNum] = {
165
- includeConfirmation: result.includeConfirmation,
166
- isConfirmed: result.isConfirmed,
167
- };
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.',
168
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
+ };
169
534
 
170
- return next;
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();
171
546
  });
172
- };
547
+ return;
548
+ }
173
549
 
174
- fetchCaseConfirmationStatuses();
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
+ }
175
559
 
176
- return () => {
177
- isCancelled = true;
178
- };
179
- }, [isOpen, currentPage, cases, user, caseConfirmationStatus]);
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
+ };
180
571
 
181
572
  if (!isOpen) return null;
182
573
 
@@ -191,62 +582,251 @@ export const CasesModal = ({
191
582
  <h2>All Cases</h2>
192
583
  <button className={styles.closeButton} {...getCloseButtonProps({ ariaLabel: 'Close cases dialog' })}>&times;</button>
193
584
  </header>
194
-
585
+
195
586
  <div className={styles.modalContent}>
196
587
  {isLoading ? (
197
588
  <p className={styles.loading}>Loading cases...</p>
198
589
  ) : error ? (
199
590
  <p className={styles.error}>{error}</p>
200
- ) : cases.length === 0 ? (
591
+ ) : allCases.length === 0 ? (
201
592
  <p className={styles.emptyState}>No cases found</p>
202
593
  ) : (
203
- <ul className={styles.casesList}>
204
- {paginatedCases.map((caseNum) => {
205
- const confirmationStatus = caseConfirmationStatus[caseNum];
206
- let confirmationClass = '';
207
-
208
- if (confirmationStatus?.includeConfirmation) {
209
- confirmationClass = confirmationStatus.isConfirmed
210
- ? styles.caseItemConfirmed
211
- : styles.caseItemNotConfirmed;
212
- }
213
-
214
- return (
215
- <li key={caseNum}>
216
- <button
217
- className={`${styles.caseItem} ${currentCase === caseNum ? styles.active : ''} ${confirmationClass}`}
218
- onClick={() => {
219
- onSelectCase(caseNum);
220
- requestClose();
221
- }}
222
- >
223
- {caseNum}
224
- </button>
225
- </li>
226
- );
227
- })}
228
- </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
+ </>
229
736
  )}
230
737
  </div>
231
-
232
- {totalPages > 1 && (
233
- <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>
234
752
  <button
235
- onClick={() => setCurrentPage(p => p - 1)}
236
- disabled={currentPage === 0}
753
+ type="button"
754
+ className={`${styles.secondaryActionButton} ${styles.renameActionButton}`}
755
+ onClick={handleRenameSelectedCase}
756
+ disabled={!canRenameSelectedCase || isRunningAction}
237
757
  >
238
- Previous
758
+ Rename Selected
239
759
  </button>
240
- <span>{currentPage + 1} of {totalPages} ({cases.length} total cases)</span>
241
760
  <button
242
- onClick={() => setCurrentPage(p => p + 1)}
243
- disabled={currentPage === totalPages - 1}
761
+ type="button"
762
+ className={`${styles.secondaryActionButton} ${styles.archiveActionButton}`}
763
+ onClick={handleArchiveSelectedCase}
764
+ disabled={!canArchiveSelectedCase || isRunningAction}
244
765
  >
245
- Next
766
+ Archive Selected
767
+ </button>
768
+ <button
769
+ type="button"
770
+ className={`${styles.secondaryActionButton} ${styles.deleteActionButton}`}
771
+ onClick={handleDeleteSelectedCase}
772
+ disabled={!canDeleteSelectedCase || isRunningAction}
773
+ >
774
+ Delete Selected
246
775
  </button>
247
776
  </div>
248
- )}
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>
249
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
+ />
250
830
  </div>
251
831
  );
252
832
  };