@propriety/court-calendar 0.0.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.
- package/.editorconfig +26 -0
- package/README.md +0 -0
- package/biome.json +302 -0
- package/dev/App.tsx +51 -0
- package/dev/main.tsx +10 -0
- package/index.html +12 -0
- package/package.json +54 -0
- package/public/vite.svg +1 -0
- package/src/_components/CCalendar.css +463 -0
- package/src/_components/CCalendar.tsx +726 -0
- package/src/_components/List/CalendarList.tsx +288 -0
- package/src/_components/Modal/CaseDetails/CaseDetails.tsx +414 -0
- package/src/_components/Modal/CaseDetails/EvidenceRow.tsx +83 -0
- package/src/_components/Modal/CaseDetails/EvidenceSection.tsx +94 -0
- package/src/_components/Modal/CreateEdit/CreateEditCase.tsx +241 -0
- package/src/_components/Modal/CreateEdit/DateSelector.tsx +42 -0
- package/src/_components/Modal/CreateEdit/EditUserFieldDropdown.tsx +54 -0
- package/src/_components/Modal/CreateEdit/EnumDropdown.tsx +54 -0
- package/src/_components/Modal/CreateEdit/HearingOfficerDropdown.tsx +48 -0
- package/src/_components/Modal/CreateEdit/TextFieldList.tsx +186 -0
- package/src/_components/Modal/CreateEdit/ToggleableTextField.tsx +91 -0
- package/src/_components/Modal/Modal.css +15 -0
- package/src/_components/Modal/Modal.tsx +325 -0
- package/src/_components/Modal/ModalActions.tsx +99 -0
- package/src/_components/Modal/View/CaseToolbar.tsx +81 -0
- package/src/_components/Modal/View/CaseViewer.tsx +237 -0
- package/src/_components/Modal/View/DateDetails.tsx +138 -0
- package/src/_components/Modal/View/InfoBox.tsx +22 -0
- package/src/_components/Modal/View/InfoBoxBtn.css +39 -0
- package/src/_components/Modal/View/InfoBoxBtn.tsx +29 -0
- package/src/_components/Modal/View/NoticeFileLink.tsx +44 -0
- package/src/_components/Shared/FirstSecondChairIcons.tsx +247 -0
- package/src/_components/Shared/FormRow.tsx +37 -0
- package/src/_components/Shared/MuniDropdown.tsx +94 -0
- package/src/_components/Shared/SearchBar.tsx +87 -0
- package/src/_components/Toolbar/CaseFilter.tsx +77 -0
- package/src/_components/Toolbar/DateTypeFilter.tsx +63 -0
- package/src/_components/Toolbar/HearingTypeFilter.tsx +63 -0
- package/src/_components/Toolbar/Toolbar.tsx +159 -0
- package/src/_components/Toolbar/UserFilter.tsx +105 -0
- package/src/_components/Toolbar/ViewFilter.tsx +48 -0
- package/src/helpers/cache.ts +89 -0
- package/src/helpers/cases.ts +79 -0
- package/src/helpers/courtDates.ts +139 -0
- package/src/helpers/formatter.ts +16 -0
- package/src/helpers/munis.ts +44 -0
- package/src/helpers/people.ts +46 -0
- package/src/index.ts +2 -0
- package/src/types.ts +129 -0
- package/tsconfig.app.json +32 -0
- package/tsconfig.json +4 -0
- package/tsconfig.node.json +30 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
// #region imports
|
|
2
|
+
import FullCalendar from '@fullcalendar/react';
|
|
3
|
+
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
4
|
+
import timeGridPlugin from '@fullcalendar/timegrid';
|
|
5
|
+
import interactionPlugin, { type DateClickArg } from '@fullcalendar/interaction';
|
|
6
|
+
import listPlugin from '@fullcalendar/list';
|
|
7
|
+
import { useEffect, useRef, useState, useMemo } from 'react';
|
|
8
|
+
import './CCalendar.css';
|
|
9
|
+
import Toolbar from './Toolbar/Toolbar';
|
|
10
|
+
import { Calendar, type EventMountArg, type EventSourceInput, type EventClickArg } from '@fullcalendar/core/index.js';
|
|
11
|
+
import {
|
|
12
|
+
HearingType,
|
|
13
|
+
Lifecycle,
|
|
14
|
+
ModalMode,
|
|
15
|
+
SourceType,
|
|
16
|
+
Status,
|
|
17
|
+
type CalendarFilterCtx,
|
|
18
|
+
type Case,
|
|
19
|
+
type CourtDate,
|
|
20
|
+
} from '@/types';
|
|
21
|
+
import { Tooltip } from 'react-tooltip';
|
|
22
|
+
import Modal from './Modal/Modal';
|
|
23
|
+
import { getAllDates, isVillageDate, updateCourtDate } from '@/helpers/courtDates';
|
|
24
|
+
import { getTownshipName } from '@/helpers/munis';
|
|
25
|
+
import Typography from '@mui/material/Typography';
|
|
26
|
+
import Stack from '@mui/material/Stack';
|
|
27
|
+
import {
|
|
28
|
+
fetchAllCasesPaginated,
|
|
29
|
+
fetchCasesByCourtDate,
|
|
30
|
+
isCaseMissingEvidence,
|
|
31
|
+
isCaseSettled,
|
|
32
|
+
searchByCaseTerm,
|
|
33
|
+
} from '@/helpers/cases';
|
|
34
|
+
import {
|
|
35
|
+
getCachedCases,
|
|
36
|
+
removeCasesCache,
|
|
37
|
+
updateCasesCache,
|
|
38
|
+
getCourtDatesCache,
|
|
39
|
+
setCourtDatesCache,
|
|
40
|
+
} from '@/helpers/cache';
|
|
41
|
+
import CalendarList from './List/CalendarList';
|
|
42
|
+
// #endregion
|
|
43
|
+
export default function CCalendar({ apiKey, activeUser }: { apiKey: string; activeUser: number }) {
|
|
44
|
+
// #region state
|
|
45
|
+
const [calendarApi, setCalendarApi] = useState<Calendar | null>(null);
|
|
46
|
+
const [courtDates, setCourtDates] = useState<Array<CourtDate>>([]);
|
|
47
|
+
const [events, setEvents] = useState<EventSourceInput>([]);
|
|
48
|
+
const [eventHash, setEventHash] = useState<string>('');
|
|
49
|
+
const [modalIsOpen, setIsOpen] = useState(false);
|
|
50
|
+
const [selectedCourtDate, setSelectedCourtDate] = useState<CourtDate | null>(null);
|
|
51
|
+
const calendarRef = useRef<FullCalendar | null>(null);
|
|
52
|
+
const [filterCtx, setFilterCtx] = useState<CalendarFilterCtx>({
|
|
53
|
+
showInPerson: true,
|
|
54
|
+
showVirtual: true,
|
|
55
|
+
showUnknown: true,
|
|
56
|
+
showOnlyAssignedToUser: null,
|
|
57
|
+
showUploadDeadlines: false,
|
|
58
|
+
showAdjournmentDates: true,
|
|
59
|
+
showCourtDates: true,
|
|
60
|
+
searchTerm: '',
|
|
61
|
+
municode: null,
|
|
62
|
+
user: null,
|
|
63
|
+
showOnlyUnsettled: false,
|
|
64
|
+
showOnlyWithoutEvidence: false,
|
|
65
|
+
showOnlyUnreviewed: false,
|
|
66
|
+
});
|
|
67
|
+
const [currentView, setCurrentView] = useState('dayGridMonth');
|
|
68
|
+
const [currentDate, setCurrentDate] = useState<Date>(new Date());
|
|
69
|
+
const [modalMode, setModalMode] = useState<ModalMode>(ModalMode.DETAILS);
|
|
70
|
+
const [scrollbarClicked, setScrollbarClicked] = useState(false);
|
|
71
|
+
const [allCases, setAllCases] = useState<Record<string, Case[]>>({});
|
|
72
|
+
const [selectedCases, setSelectedCases] = useState<Case[]>([]);
|
|
73
|
+
const [isFetchingCases, setIsFetchingCases] = useState<boolean>(false);
|
|
74
|
+
const [searchedDateIDs, setSearchedDateIDs] = useState<Array<number>>([]);
|
|
75
|
+
// Track previously rendered event IDs to avoid reanimating unchanged events
|
|
76
|
+
const prevEventIdsRef = useRef<Set<string>>(new Set());
|
|
77
|
+
// Track previous view to detect actual view changes vs calendarApi remounts
|
|
78
|
+
const prevViewRef = useRef<string>(currentView);
|
|
79
|
+
// #endregion
|
|
80
|
+
|
|
81
|
+
// #region initializer
|
|
82
|
+
async function getAllCourtDates(apiKey: string) {
|
|
83
|
+
// Try to load from IndexedDB cache
|
|
84
|
+
const cachedDates = await getCourtDatesCache();
|
|
85
|
+
if (cachedDates) {
|
|
86
|
+
setCourtDates(cachedDates);
|
|
87
|
+
} else {
|
|
88
|
+
getAllDates(apiKey)
|
|
89
|
+
.then(async (dates) => {
|
|
90
|
+
setCourtDates(dates);
|
|
91
|
+
await setCourtDatesCache(dates);
|
|
92
|
+
})
|
|
93
|
+
.catch((error) => {
|
|
94
|
+
console.error('Failed to fetch court dates:', error);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
getAllCourtDates(apiKey);
|
|
101
|
+
}, []);
|
|
102
|
+
// #endregion
|
|
103
|
+
|
|
104
|
+
// #region callbacks
|
|
105
|
+
function updateCourtDateInMemory(updatedDate: CourtDate, del?: boolean) {
|
|
106
|
+
setCourtDates((prevDates) => {
|
|
107
|
+
const index = prevDates.findIndex((date) => date.CourtDateID === updatedDate.CourtDateID);
|
|
108
|
+
let newDates;
|
|
109
|
+
if (index !== -1) {
|
|
110
|
+
newDates = [...prevDates];
|
|
111
|
+
if (del) {
|
|
112
|
+
newDates.splice(index, 1);
|
|
113
|
+
} else {
|
|
114
|
+
newDates[index] = updatedDate;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
newDates = [...prevDates, updatedDate];
|
|
118
|
+
}
|
|
119
|
+
// Defer cache update to not block render
|
|
120
|
+
queueMicrotask(() => setCourtDatesCache(newDates));
|
|
121
|
+
return newDates;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleUpdateChair(courtDateId: number, position: 'first' | 'second', userId: number | null) {
|
|
126
|
+
const courtDate = courtDates.find((d) => d.CourtDateID === courtDateId);
|
|
127
|
+
if (!courtDate) return;
|
|
128
|
+
|
|
129
|
+
const updatedData: Partial<CourtDate> = position === 'first' ? { FirstChair: userId } : { SecondChair: userId };
|
|
130
|
+
const updatedCourtDate = { ...courtDate, ...updatedData };
|
|
131
|
+
|
|
132
|
+
// Optimistic update - update UI immediately
|
|
133
|
+
updateCourtDateInMemory(updatedCourtDate);
|
|
134
|
+
|
|
135
|
+
// Then persist to API in background
|
|
136
|
+
updateCourtDate(courtDateId, updatedData, apiKey).then((success) => {
|
|
137
|
+
if (!success) {
|
|
138
|
+
// Revert on failure
|
|
139
|
+
updateCourtDateInMemory(courtDate);
|
|
140
|
+
console.error('Failed to update chair assignment, reverted');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// #endregion
|
|
146
|
+
|
|
147
|
+
// #region case fetching
|
|
148
|
+
// there will be 3 sources for data, in order of priority:
|
|
149
|
+
// 1. memory (allCases)
|
|
150
|
+
// 2. localStorage cache
|
|
151
|
+
// 3. fetch from API
|
|
152
|
+
// whenever we fetch from a place lower in priority, we update the higher places accordingly
|
|
153
|
+
|
|
154
|
+
// FETCHING ALL:
|
|
155
|
+
// fetching all cases for all court dates at once can be heavy, so we will fetch individually as needed
|
|
156
|
+
// when we fetch all cases, we will always check memory first since it is likely up to date and free
|
|
157
|
+
// if not in memory, we will check localStorage cache next
|
|
158
|
+
// if its in cache and not expired, we will use that
|
|
159
|
+
// if not in cache or expired, we will fetch from API
|
|
160
|
+
|
|
161
|
+
// FETCHING INDIVIDUAL:
|
|
162
|
+
// when fetching cases for a single court date (when clicked), we will always fetch from API to ensure up to date data
|
|
163
|
+
// but we will still update memory and cache accordingly
|
|
164
|
+
async function getCases(
|
|
165
|
+
courtDateIDs: number[],
|
|
166
|
+
skipMemory: boolean,
|
|
167
|
+
skipCache: boolean,
|
|
168
|
+
): Promise<Record<string, Case[]>> {
|
|
169
|
+
console.log(`Looking for ${courtDateIDs.length} court dates' cases...`);
|
|
170
|
+
setIsFetchingCases(true);
|
|
171
|
+
const memoryCasesByCourtDate: Record<string, Case[]> = {};
|
|
172
|
+
const memoryMissingIDs: string[] = [];
|
|
173
|
+
|
|
174
|
+
const cachedCasesByCourtDate: Record<string, Case[]> = {};
|
|
175
|
+
const cachedMissingIDs: string[] = [];
|
|
176
|
+
|
|
177
|
+
const fetchedCasesByCourtDate: Record<string, Case[]> = {};
|
|
178
|
+
const fetchedMissingIDs: string[] = [];
|
|
179
|
+
|
|
180
|
+
// try memory
|
|
181
|
+
courtDateIDs.forEach((id) => {
|
|
182
|
+
const key = id.toString();
|
|
183
|
+
if (!skipMemory && allCases[key]) {
|
|
184
|
+
memoryCasesByCourtDate[key] = allCases[key];
|
|
185
|
+
} else {
|
|
186
|
+
memoryMissingIDs.push(key);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// try cache for missing memory cases
|
|
191
|
+
console.log(
|
|
192
|
+
`Found ${memoryMissingIDs.length} missing from memory, checking cache for ${memoryMissingIDs.length} cases...`,
|
|
193
|
+
);
|
|
194
|
+
// Use for...of to await sequentially so cachedMissingIDs is correct
|
|
195
|
+
for (const id of memoryMissingIDs) {
|
|
196
|
+
const cached = !skipCache ? await getCachedCases(id.toString()) : null;
|
|
197
|
+
if (cached) {
|
|
198
|
+
cachedCasesByCourtDate[id] = cached;
|
|
199
|
+
} else {
|
|
200
|
+
cachedMissingIDs.push(id);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await addPartialCasesToMemoryAndCache(cachedCasesByCourtDate, true); // skip cache since these are from cache
|
|
205
|
+
|
|
206
|
+
// fetch from API for missing cache cases
|
|
207
|
+
console.log(
|
|
208
|
+
`Found ${cachedMissingIDs.length} missing from cache, fetching ${cachedMissingIDs.length} cases from API...`,
|
|
209
|
+
);
|
|
210
|
+
// if all more than 10 are missing, call the batched fetch function
|
|
211
|
+
if (cachedMissingIDs.length > 10) {
|
|
212
|
+
for await (const fetched of fetchAllCasesPaginated(apiKey)) {
|
|
213
|
+
Object.entries(fetched).forEach(([courtDateIDStr, cases]) => {
|
|
214
|
+
if (cachedMissingIDs.includes(courtDateIDStr)) {
|
|
215
|
+
fetchedCasesByCourtDate[courtDateIDStr] = cases;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
// need to update memory and stuff within the loop since this is a generator
|
|
219
|
+
await addPartialCasesToMemoryAndCache(fetchedCasesByCourtDate, false);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
// fetch individually
|
|
223
|
+
await Promise.all(
|
|
224
|
+
cachedMissingIDs.map(async (id) => {
|
|
225
|
+
const fetched = await fetchCasesByCourtDate(id, apiKey);
|
|
226
|
+
if (fetched && fetched.length > 0) {
|
|
227
|
+
fetchedCasesByCourtDate[id] = fetched;
|
|
228
|
+
} else {
|
|
229
|
+
fetchedMissingIDs.push(id);
|
|
230
|
+
}
|
|
231
|
+
await addPartialCasesToMemoryAndCache(fetchedCasesByCourtDate, false);
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// if there are any missing IDs after all that, throw an error
|
|
237
|
+
if (fetchedMissingIDs.length > 0) {
|
|
238
|
+
console.error('Failed to fetch cases for court date IDs:', fetchedMissingIDs);
|
|
239
|
+
}
|
|
240
|
+
setIsFetchingCases(false);
|
|
241
|
+
// log number of cases fetched from each source
|
|
242
|
+
console.log(
|
|
243
|
+
`Cases fetched - Memory: ${Object.keys(memoryCasesByCourtDate).length}, Cache: ${Object.keys(cachedCasesByCourtDate).length}, Fetched: ${Object.keys(fetchedCasesByCourtDate).length}`,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Return combined cases from all sources
|
|
247
|
+
return { ...memoryCasesByCourtDate, ...cachedCasesByCourtDate, ...fetchedCasesByCourtDate };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// add partial cases to memory without overwriting existing ones
|
|
251
|
+
async function addPartialCasesToMemoryAndCache(cases: Record<string, Case[]>, skipCache: boolean) {
|
|
252
|
+
// Update memory with any newly fetched or cached cases
|
|
253
|
+
console.log(
|
|
254
|
+
`Adding ${Object.keys(cases).length} partial cases to ${skipCache ? 'memory only' : 'memory and cache'}...`,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
setAllCases((prev) => {
|
|
258
|
+
const newCases = { ...prev };
|
|
259
|
+
Object.entries(cases).forEach(([courtDateID, cases]) => {
|
|
260
|
+
newCases[courtDateID.toString()] = cases;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return newCases;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Update cache with any newly fetched cases
|
|
267
|
+
if (skipCache) return;
|
|
268
|
+
await Promise.all(Object.entries(cases).map(([courtDateID, cases]) => updateCasesCache(courtDateID, cases)));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function deleteCaseMemoryAndCache(courtDateID: string) {
|
|
272
|
+
setAllCases((prev) => {
|
|
273
|
+
const newCases = { ...prev };
|
|
274
|
+
delete newCases[courtDateID];
|
|
275
|
+
return newCases;
|
|
276
|
+
});
|
|
277
|
+
await removeCasesCache(courtDateID);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ALL DATES
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (courtDates.length === 0) return;
|
|
283
|
+
// fetch cases for all court dates in memory and cache
|
|
284
|
+
(async () => {
|
|
285
|
+
await getCases(
|
|
286
|
+
courtDates.map((cd) => cd.CourtDateID),
|
|
287
|
+
false, // dont skip memory
|
|
288
|
+
false, // dont skip cache
|
|
289
|
+
);
|
|
290
|
+
})();
|
|
291
|
+
}, [courtDates]);
|
|
292
|
+
|
|
293
|
+
// SELECTED DATE
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (!selectedCourtDate) return;
|
|
296
|
+
// Skip fetch for new court dates (negative ID means it hasn't been created yet)
|
|
297
|
+
if (selectedCourtDate.CourtDateID < 0) {
|
|
298
|
+
setSelectedCases([]);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// fetch cases for selected court date
|
|
302
|
+
(async () => {
|
|
303
|
+
const fetchedCases = await getCases(
|
|
304
|
+
[selectedCourtDate.CourtDateID],
|
|
305
|
+
true, // skip memory
|
|
306
|
+
true, // skip cache
|
|
307
|
+
);
|
|
308
|
+
// Use the returned cases directly to avoid stale closure
|
|
309
|
+
setSelectedCases(fetchedCases[selectedCourtDate.CourtDateID.toString()] || []);
|
|
310
|
+
})();
|
|
311
|
+
}, [selectedCourtDate]);
|
|
312
|
+
|
|
313
|
+
// #endregion
|
|
314
|
+
|
|
315
|
+
// #region click event handlers
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
// --- Scrollbar click detection ---
|
|
318
|
+
const handler = (e: MouseEvent) => {
|
|
319
|
+
// Only check for left click
|
|
320
|
+
if (e.button !== 0) return;
|
|
321
|
+
// Look for scrollable event area
|
|
322
|
+
const target = e.target as HTMLElement;
|
|
323
|
+
if (target && target.classList.contains('fc-daygrid-day-events')) {
|
|
324
|
+
const { offsetWidth, clientWidth, offsetHeight, clientHeight } = target;
|
|
325
|
+
// Vertical scrollbar
|
|
326
|
+
console.log(offsetWidth, clientWidth, offsetHeight, clientHeight);
|
|
327
|
+
if (offsetWidth > clientWidth - 10) {
|
|
328
|
+
const scrollbarX = clientWidth;
|
|
329
|
+
if (e.offsetX >= scrollbarX - 10) {
|
|
330
|
+
setScrollbarClicked(true);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
setScrollbarClicked(false);
|
|
336
|
+
};
|
|
337
|
+
document.addEventListener('mousedown', handler, true);
|
|
338
|
+
|
|
339
|
+
return () => {
|
|
340
|
+
document.removeEventListener('mousedown', handler, true);
|
|
341
|
+
};
|
|
342
|
+
}, []);
|
|
343
|
+
|
|
344
|
+
function handleDateClick(dateClickInfo: DateClickArg) {
|
|
345
|
+
if (scrollbarClicked) {
|
|
346
|
+
setScrollbarClicked(false);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
console.log('Date clicked:', dateClickInfo.dateStr);
|
|
350
|
+
setCurrentView('listDay');
|
|
351
|
+
setCurrentDate(dateClickInfo.date);
|
|
352
|
+
if (calendarApi) {
|
|
353
|
+
calendarApi.gotoDate(dateClickInfo.date);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function createCourtDate(dateClickInfo: Date) {
|
|
358
|
+
console.log('Date clicked:', dateClickInfo?.toISOString());
|
|
359
|
+
setSelectedCourtDate({
|
|
360
|
+
CourtDateID: -1,
|
|
361
|
+
CourtDate: dateClickInfo || null,
|
|
362
|
+
MuniCode: '',
|
|
363
|
+
Lifecycle: Lifecycle.SCHEDULED,
|
|
364
|
+
CourtCases: 0,
|
|
365
|
+
UploadDeadline: null,
|
|
366
|
+
HearingTime: '',
|
|
367
|
+
HearingLink: null,
|
|
368
|
+
Source: SourceType.MANUAL,
|
|
369
|
+
Type: HearingType.UNKNOWN,
|
|
370
|
+
FirstChair: null,
|
|
371
|
+
SecondChair: null,
|
|
372
|
+
HearingOfficer: null,
|
|
373
|
+
EnterDate: new Date(),
|
|
374
|
+
LastUpdateDate: new Date(),
|
|
375
|
+
AdjournmentDate: null,
|
|
376
|
+
IsAdjourned: false,
|
|
377
|
+
Notes: null,
|
|
378
|
+
NoticeFile: null,
|
|
379
|
+
});
|
|
380
|
+
setModalMode(ModalMode.CREATE);
|
|
381
|
+
setIsOpen(true);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function handleEventClick(clickInfo: EventClickArg) {
|
|
385
|
+
const courtDate = clickInfo.event.extendedProps as CourtDate;
|
|
386
|
+
clickCallback(courtDate);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function clickCallback(date: CourtDate) {
|
|
390
|
+
setSelectedCourtDate(date);
|
|
391
|
+
setIsOpen(true);
|
|
392
|
+
}
|
|
393
|
+
// #endregion
|
|
394
|
+
|
|
395
|
+
// #region search
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
setSearchedDateIDs([]);
|
|
398
|
+
if (filterCtx.searchTerm.trim() === '') return;
|
|
399
|
+
(async () => {
|
|
400
|
+
for await (const result of searchByCaseTerm(filterCtx.searchTerm, apiKey)) {
|
|
401
|
+
setSearchedDateIDs((prev) => [...prev, ...result]);
|
|
402
|
+
}
|
|
403
|
+
})();
|
|
404
|
+
}, [filterCtx.searchTerm]);
|
|
405
|
+
// #endregion
|
|
406
|
+
|
|
407
|
+
// #region filtering
|
|
408
|
+
const filteredDates = useMemo(() => {
|
|
409
|
+
return courtDates
|
|
410
|
+
.filter((date) => {
|
|
411
|
+
if (searchedDateIDs.length === 0 || filterCtx.searchTerm.trim() === '') return true;
|
|
412
|
+
return searchedDateIDs.includes(date.CourtDateID);
|
|
413
|
+
})
|
|
414
|
+
.filter((date) => {
|
|
415
|
+
if (!filterCtx.showInPerson) {
|
|
416
|
+
return date.Type !== HearingType.IN_PERSON;
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
})
|
|
420
|
+
.filter((date) => {
|
|
421
|
+
if (!filterCtx.showVirtual) {
|
|
422
|
+
return date.Type !== HearingType.VIRTUAL;
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
})
|
|
426
|
+
.filter((date) => {
|
|
427
|
+
if (!filterCtx.showUnknown) {
|
|
428
|
+
return date.Type !== HearingType.UNKNOWN && date.Type !== null;
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
})
|
|
432
|
+
.filter((date) => {
|
|
433
|
+
if (filterCtx.showOnlyAssignedToUser !== null) {
|
|
434
|
+
return (
|
|
435
|
+
date.FirstChair === filterCtx.showOnlyAssignedToUser ||
|
|
436
|
+
date.SecondChair === filterCtx.showOnlyAssignedToUser
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
return true;
|
|
440
|
+
})
|
|
441
|
+
.filter((date) => {
|
|
442
|
+
if (filterCtx.municode) {
|
|
443
|
+
return date.MuniCode === filterCtx.municode;
|
|
444
|
+
}
|
|
445
|
+
return true;
|
|
446
|
+
})
|
|
447
|
+
.filter((date) => {
|
|
448
|
+
if (filterCtx.showOnlyUnsettled) {
|
|
449
|
+
for (const c of allCases[date.CourtDateID.toString()] || []) {
|
|
450
|
+
if (!isCaseSettled(c, isVillageDate(date.MuniCode))) {
|
|
451
|
+
return true; // has at least one unsettled case
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return false; // all cases are settled, exclude
|
|
455
|
+
}
|
|
456
|
+
return true;
|
|
457
|
+
})
|
|
458
|
+
.filter((date) => {
|
|
459
|
+
if (filterCtx.showOnlyWithoutEvidence) {
|
|
460
|
+
for (const c of allCases[date.CourtDateID.toString()] || []) {
|
|
461
|
+
if (isCaseMissingEvidence(c)) {
|
|
462
|
+
return true; // has at least one case missing evidence
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return false; // all cases have evidence, exclude
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
})
|
|
469
|
+
.filter((date) => {
|
|
470
|
+
if (filterCtx.showOnlyUnreviewed) {
|
|
471
|
+
for (const c of allCases[date.CourtDateID.toString()] || []) {
|
|
472
|
+
if (!c.DateCompleted) {
|
|
473
|
+
return true; // has at least one unreviewed case
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return false; // all cases reviewed, exclude
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
});
|
|
480
|
+
}, [courtDates, filterCtx, activeUser, allCases, searchedDateIDs]);
|
|
481
|
+
|
|
482
|
+
const cdates = useMemo(() => {
|
|
483
|
+
if (!filterCtx.showCourtDates) return [];
|
|
484
|
+
return filteredDates
|
|
485
|
+
.filter((date) => date.CourtDate !== null)
|
|
486
|
+
.filter((date) => !date.IsAdjourned)
|
|
487
|
+
.map((date) => ({
|
|
488
|
+
id: date.CourtDateID.toString(),
|
|
489
|
+
title: `${getTownshipName(date.MuniCode)} (${date.MuniCode}) x${date.CourtCases}`,
|
|
490
|
+
start: date.CourtDate,
|
|
491
|
+
allDay: false,
|
|
492
|
+
extendedProps: date,
|
|
493
|
+
classNames: ['courtdate-event', date.Type?.toString().toLowerCase() || 'unknown'],
|
|
494
|
+
}));
|
|
495
|
+
}, [filteredDates, filterCtx]);
|
|
496
|
+
|
|
497
|
+
const deadlines = useMemo(() => {
|
|
498
|
+
if (!filterCtx.showUploadDeadlines) return [];
|
|
499
|
+
return filteredDates
|
|
500
|
+
.filter((date) => date.UploadDeadline !== null)
|
|
501
|
+
.map((date) => ({
|
|
502
|
+
id: `deadline-${date.CourtDateID}`,
|
|
503
|
+
title: `Upload ${getTownshipName(date.MuniCode)} (${date.MuniCode})`,
|
|
504
|
+
start: date.UploadDeadline!,
|
|
505
|
+
allDay: true,
|
|
506
|
+
extendedProps: date,
|
|
507
|
+
classNames: [
|
|
508
|
+
'deadline-event',
|
|
509
|
+
date.Type?.toString().toLowerCase() || 'unknown',
|
|
510
|
+
date.IsAdjourned && !date.AdjournmentDate ? 'adjourned-no-date' : '',
|
|
511
|
+
date.Lifecycle === Lifecycle.UPLOADED
|
|
512
|
+
? 'status-uploaded'
|
|
513
|
+
: date.Lifecycle === Lifecycle.ASSIGNED
|
|
514
|
+
? 'status-assigned'
|
|
515
|
+
: 'status-unknown',
|
|
516
|
+
],
|
|
517
|
+
}));
|
|
518
|
+
}, [filteredDates, filterCtx]);
|
|
519
|
+
|
|
520
|
+
const adates = useMemo(() => {
|
|
521
|
+
if (!filterCtx.showAdjournmentDates) return [];
|
|
522
|
+
return filteredDates
|
|
523
|
+
.filter((date) => date.IsAdjourned)
|
|
524
|
+
.map((date) => ({
|
|
525
|
+
id: `adjournment-${date.CourtDateID}`,
|
|
526
|
+
title: `Adjournment - ${date.MuniCode}`,
|
|
527
|
+
start: date.AdjournmentDate ?? date.CourtDate,
|
|
528
|
+
allDay: false,
|
|
529
|
+
extendedProps: date,
|
|
530
|
+
classNames: [
|
|
531
|
+
'adjournment-event',
|
|
532
|
+
date.Type?.toString().toLowerCase() || 'unknown',
|
|
533
|
+
date.IsAdjourned && !date.AdjournmentDate ? 'adjourned-no-date' : '',
|
|
534
|
+
],
|
|
535
|
+
}));
|
|
536
|
+
}, [filteredDates, filterCtx]);
|
|
537
|
+
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
if (modalIsOpen) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
setEvents([...cdates, ...deadlines, ...adates]);
|
|
543
|
+
setEventHash(JSON.stringify([...cdates, ...deadlines, ...adates]));
|
|
544
|
+
}, [cdates, deadlines, adates, modalIsOpen]);
|
|
545
|
+
// #endregion
|
|
546
|
+
|
|
547
|
+
// #region calendar view management
|
|
548
|
+
const handleCalendarRef = (el: FullCalendar | null) => {
|
|
549
|
+
if (el) {
|
|
550
|
+
calendarRef.current = el;
|
|
551
|
+
setCalendarApi(el.getApi() as Calendar);
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
useEffect(() => {
|
|
556
|
+
if (currentView === 'tableView') {
|
|
557
|
+
if (calendarApi) {
|
|
558
|
+
calendarApi.changeView('dayGridMonth');
|
|
559
|
+
}
|
|
560
|
+
prevViewRef.current = currentView;
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (calendarApi) {
|
|
564
|
+
calendarApi.changeView(currentView);
|
|
565
|
+
}
|
|
566
|
+
// Only reset to today when actually switching TO dayGridMonth, not on calendarApi remount
|
|
567
|
+
if (currentView === 'dayGridMonth' && prevViewRef.current !== 'dayGridMonth') {
|
|
568
|
+
setCurrentDate(new Date());
|
|
569
|
+
if (calendarApi) {
|
|
570
|
+
calendarApi.gotoDate(new Date());
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
prevViewRef.current = currentView;
|
|
574
|
+
}, [currentView, calendarApi]);
|
|
575
|
+
// #endregion
|
|
576
|
+
|
|
577
|
+
// #region Event Mounting (Tooltips & Animations)
|
|
578
|
+
function handleEventMount(info: EventMountArg) {
|
|
579
|
+
// biome-ignore lint/suspicious/noExplicitAny: i dont know the type
|
|
580
|
+
let newVisibleEvents: any[] = [];
|
|
581
|
+
let newEventIndex = 0;
|
|
582
|
+
let viewStart: Date | null = null;
|
|
583
|
+
let viewEnd: Date | null = null;
|
|
584
|
+
if (calendarApi) {
|
|
585
|
+
viewStart = calendarApi.view.activeStart;
|
|
586
|
+
viewEnd = calendarApi.view.activeEnd;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Helper to check if event is visible in current view
|
|
590
|
+
// biome-ignore lint/suspicious/noExplicitAny: i dont know the type
|
|
591
|
+
function isEventVisible(ev: any) {
|
|
592
|
+
if (!viewStart || !viewEnd) return true;
|
|
593
|
+
const evStart = new Date(ev.start);
|
|
594
|
+
// For allDay events, treat as visible if any overlap
|
|
595
|
+
if (ev.allDay) {
|
|
596
|
+
return evStart >= viewStart && evStart < viewEnd;
|
|
597
|
+
}
|
|
598
|
+
return evStart >= viewStart && evStart < viewEnd;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (Array.isArray(events)) {
|
|
602
|
+
// Only consider NEW events (not previously rendered) for animation delay calculation
|
|
603
|
+
newVisibleEvents = events.filter(isEventVisible).filter((e) => {
|
|
604
|
+
const id = e.id?.toString();
|
|
605
|
+
return id && !prevEventIdsRef.current.has(id);
|
|
606
|
+
});
|
|
607
|
+
newEventIndex = newVisibleEvents.findIndex((e) => e.id === info.event.id);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Only animate if this event is new (not in previous set)
|
|
611
|
+
const eventId = info.event.id?.toString();
|
|
612
|
+
if (eventId && !prevEventIdsRef.current.has(eventId)) {
|
|
613
|
+
info.el.classList.add('pop-in-ccalendar-event');
|
|
614
|
+
info.el.style.animationDelay = `${30 * (newEventIndex >= 0 ? newEventIndex : 0)}ms`;
|
|
615
|
+
} else {
|
|
616
|
+
info.el.classList.remove('pop-in-ccalendar-event');
|
|
617
|
+
info.el.style.animationDelay = '0ms';
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Tooltip logic
|
|
621
|
+
const tooltipContent = `
|
|
622
|
+
<div><strong>${info.event.title}</strong><br/>
|
|
623
|
+
<em>Court Date:</em> ${info.event.start?.toLocaleString()}<br/>
|
|
624
|
+
${info.event.extendedProps.IsAdjourned && !info.event.extendedProps.AdjournmentDate ? '<strong>Adjourned - No Adjournment Date Set!</strong><br/>' : ''}
|
|
625
|
+
<em>Upload Deadline:</em> ${info.event.extendedProps.UploadDeadline}<br/>
|
|
626
|
+
<em>Court Cases:</em> ${info.event.extendedProps.CourtCases}<br/>
|
|
627
|
+
<em>Status:</em> ${info.event.extendedProps.Status}<br />
|
|
628
|
+
<em>First Chair:</em> ${info.event.extendedProps.FirstChair ?? 'Unassigned'}<br/>
|
|
629
|
+
<em>Click for more details</em>
|
|
630
|
+
</div>
|
|
631
|
+
`;
|
|
632
|
+
info.el.setAttribute('data-tooltip-id', 'event-tooltip');
|
|
633
|
+
info.el.setAttribute('data-tooltip-html', tooltipContent);
|
|
634
|
+
}
|
|
635
|
+
// Update the set of visible event IDs after each render of events, but defer so eventDidMount runs first
|
|
636
|
+
useEffect(() => {
|
|
637
|
+
if (Array.isArray(events)) {
|
|
638
|
+
const ids = new Set(events.map((e: any) => e.id?.toString()).filter(Boolean));
|
|
639
|
+
// Defer update so eventDidMount runs first
|
|
640
|
+
const timeout = setTimeout(() => {
|
|
641
|
+
prevEventIdsRef.current = ids;
|
|
642
|
+
}, 0);
|
|
643
|
+
return () => clearTimeout(timeout);
|
|
644
|
+
}
|
|
645
|
+
}, [events, eventHash]);
|
|
646
|
+
// #endregion
|
|
647
|
+
|
|
648
|
+
// #region render
|
|
649
|
+
return (
|
|
650
|
+
<div id='ccalendar-container'>
|
|
651
|
+
<Toolbar
|
|
652
|
+
calendarApi={calendarApi}
|
|
653
|
+
filterCtx={filterCtx}
|
|
654
|
+
setFilterCtx={setFilterCtx}
|
|
655
|
+
handleCreateClick={() => createCourtDate(calendarApi?.getDate() || currentDate || new Date())}
|
|
656
|
+
currentView={currentView}
|
|
657
|
+
setCurrentView={setCurrentView}
|
|
658
|
+
currentDate={currentDate}
|
|
659
|
+
setCurrentDate={setCurrentDate}
|
|
660
|
+
activeUser={activeUser}
|
|
661
|
+
isFetchingCases={isFetchingCases}
|
|
662
|
+
/>
|
|
663
|
+
{currentView === 'listDay' && (
|
|
664
|
+
<Stack spacing={2} direction={'column'} px={1} mb={1}>
|
|
665
|
+
{Object.values(events)
|
|
666
|
+
.filter((event) => new Date(event.start).toDateString() === currentDate.toDateString())
|
|
667
|
+
.filter((event) => event.extendedProps.Notes && event.extendedProps.Notes !== '')
|
|
668
|
+
.map((event) => (
|
|
669
|
+
<Typography key={event.id}>{`${event.title} - ${event.extendedProps.Notes}`}</Typography>
|
|
670
|
+
))}
|
|
671
|
+
</Stack>
|
|
672
|
+
)}
|
|
673
|
+
{currentView !== 'tableView' && (
|
|
674
|
+
<FullCalendar
|
|
675
|
+
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
|
|
676
|
+
key={eventHash}
|
|
677
|
+
initialView={currentView}
|
|
678
|
+
initialDate={currentDate}
|
|
679
|
+
height='auto'
|
|
680
|
+
dayCellClassNames={['month-day-cell']}
|
|
681
|
+
viewClassNames={['court-calendar']}
|
|
682
|
+
allDayClassNames={['day-cell-allday']}
|
|
683
|
+
dayHeaderClassNames={['day-header']}
|
|
684
|
+
eventClassNames={['court-event']}
|
|
685
|
+
headerToolbar={false}
|
|
686
|
+
ref={handleCalendarRef}
|
|
687
|
+
events={events}
|
|
688
|
+
eventClick={handleEventClick}
|
|
689
|
+
dateClick={handleDateClick}
|
|
690
|
+
datesSet={(dateInfo) => setCurrentDate(dateInfo.start)}
|
|
691
|
+
eventDidMount={(info) => {
|
|
692
|
+
handleEventMount(info);
|
|
693
|
+
}}
|
|
694
|
+
/>
|
|
695
|
+
)}
|
|
696
|
+
{currentView === 'tableView' && (
|
|
697
|
+
<CalendarList
|
|
698
|
+
filteredDates={filteredDates}
|
|
699
|
+
setSelectedDate={clickCallback}
|
|
700
|
+
currentDate={currentDate}
|
|
701
|
+
onUpdateChair={handleUpdateChair}
|
|
702
|
+
allCases={allCases}
|
|
703
|
+
/>
|
|
704
|
+
)}
|
|
705
|
+
<Modal
|
|
706
|
+
modalIsOpen={modalIsOpen}
|
|
707
|
+
modalMode={modalMode}
|
|
708
|
+
setModalMode={setModalMode}
|
|
709
|
+
setIsOpen={setIsOpen}
|
|
710
|
+
selectedCourtDate={selectedCourtDate}
|
|
711
|
+
updateCourtDateInMemory={updateCourtDateInMemory}
|
|
712
|
+
filterCtx={filterCtx}
|
|
713
|
+
setFilterCtx={setFilterCtx}
|
|
714
|
+
selectedCases={selectedCases}
|
|
715
|
+
updateCases={(cases) => {
|
|
716
|
+
addPartialCasesToMemoryAndCache(cases, false);
|
|
717
|
+
}}
|
|
718
|
+
deleteCases={deleteCaseMemoryAndCache}
|
|
719
|
+
isFetchingCases={isFetchingCases}
|
|
720
|
+
apiKey={apiKey}
|
|
721
|
+
/>
|
|
722
|
+
<Tooltip id='event-tooltip' className='cc-tooltip' />
|
|
723
|
+
</div>
|
|
724
|
+
);
|
|
725
|
+
// #endregion
|
|
726
|
+
}
|