@striae-org/striae 4.2.1 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/actions/case-import/confirmation-import.ts +20 -1
- package/app/components/actions/case-import/orchestrator.ts +3 -0
- package/app/components/actions/case-manage.ts +5 -1
- package/app/components/actions/confirm-export.ts +12 -3
- package/app/components/audit/viewer/audit-entries-list.tsx +20 -2
- package/app/components/audit/viewer/use-audit-viewer-export.ts +2 -2
- package/app/components/audit/viewer/use-audit-viewer-filters.ts +11 -1
- package/app/components/canvas/canvas.tsx +2 -1
- package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
- package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
- package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
- package/app/components/navbar/navbar.module.css +11 -0
- package/app/components/navbar/navbar.tsx +38 -19
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +2 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +27 -3
- package/app/components/sidebar/cases/cases-modal.module.css +312 -10
- package/app/components/sidebar/cases/cases-modal.tsx +690 -110
- package/app/components/sidebar/cases/cases.module.css +23 -0
- package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
- package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
- package/app/components/sidebar/files/files-modal.module.css +285 -44
- package/app/components/sidebar/files/files-modal.tsx +452 -145
- package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
- package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
- package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
- package/app/components/sidebar/notes/class-details-shared.ts +239 -0
- package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
- package/app/components/sidebar/notes/notes.module.css +236 -4
- package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
- package/app/components/sidebar/sidebar-container.tsx +2 -0
- package/app/components/sidebar/sidebar.tsx +8 -1
- package/app/hooks/useCaseListPreferences.ts +99 -0
- package/app/hooks/useFileListPreferences.ts +106 -0
- package/app/routes/striae/striae.tsx +45 -1
- package/app/services/audit/audit-export-csv.ts +4 -2
- package/app/services/audit/audit-export-report.ts +36 -4
- package/app/services/audit/audit.service.ts +2 -0
- package/app/services/audit/builders/audit-entry-builder.ts +1 -0
- package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -2
- package/app/types/annotations.ts +48 -1
- package/app/types/audit.ts +1 -0
- package/app/utils/data/case-filters.ts +127 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
- package/app/utils/data/file-filters.ts +201 -0
- package/app/utils/forensics/confirmation-signature.ts +20 -5
- package/functions/api/image/[[path]].ts +4 -0
- package/package.json +3 -4
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/src/signing-payload-utils.ts +5 -0
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
- package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
- package/workers/pdf-worker/src/report-layout.ts +227 -0
- package/workers/pdf-worker/src/report-types.ts +20 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
|
@@ -1,8 +1,30 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
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 [
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
196
|
+
const paginatedCases = visibleCases.slice(
|
|
73
197
|
currentPage * CASES_PER_PAGE,
|
|
74
198
|
(currentPage + 1) * CASES_PER_PAGE
|
|
75
199
|
);
|
|
76
200
|
|
|
77
|
-
const
|
|
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:
|
|
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
|
-
|
|
331
|
+
if (!isOpen || paginatedCases.length === 0) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
117
334
|
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
if (!isOpen || preferences.confirmationFilter === 'all' || archiveScopedCases.length === 0) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
127
342
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
155
|
-
|
|
453
|
+
setIsRunningAction(true);
|
|
454
|
+
setActionNotice(null);
|
|
156
455
|
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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' })}>×</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
|
-
) :
|
|
591
|
+
) : allCases.length === 0 ? (
|
|
201
592
|
<p className={styles.emptyState}>No cases found</p>
|
|
202
593
|
) : (
|
|
203
|
-
|
|
204
|
-
{
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
{
|
|
233
|
-
<div className={styles.
|
|
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
|
-
|
|
236
|
-
|
|
753
|
+
type="button"
|
|
754
|
+
className={`${styles.secondaryActionButton} ${styles.renameActionButton}`}
|
|
755
|
+
onClick={handleRenameSelectedCase}
|
|
756
|
+
disabled={!canRenameSelectedCase || isRunningAction}
|
|
237
757
|
>
|
|
238
|
-
|
|
758
|
+
Rename Selected
|
|
239
759
|
</button>
|
|
240
|
-
<span>{currentPage + 1} of {totalPages} ({cases.length} total cases)</span>
|
|
241
760
|
<button
|
|
242
|
-
|
|
243
|
-
|
|
761
|
+
type="button"
|
|
762
|
+
className={`${styles.secondaryActionButton} ${styles.archiveActionButton}`}
|
|
763
|
+
onClick={handleArchiveSelectedCase}
|
|
764
|
+
disabled={!canArchiveSelectedCase || isRunningAction}
|
|
244
765
|
>
|
|
245
|
-
|
|
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
|
};
|