@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.
Files changed (53) hide show
  1. package/.editorconfig +26 -0
  2. package/README.md +0 -0
  3. package/biome.json +302 -0
  4. package/dev/App.tsx +51 -0
  5. package/dev/main.tsx +10 -0
  6. package/index.html +12 -0
  7. package/package.json +54 -0
  8. package/public/vite.svg +1 -0
  9. package/src/_components/CCalendar.css +463 -0
  10. package/src/_components/CCalendar.tsx +726 -0
  11. package/src/_components/List/CalendarList.tsx +288 -0
  12. package/src/_components/Modal/CaseDetails/CaseDetails.tsx +414 -0
  13. package/src/_components/Modal/CaseDetails/EvidenceRow.tsx +83 -0
  14. package/src/_components/Modal/CaseDetails/EvidenceSection.tsx +94 -0
  15. package/src/_components/Modal/CreateEdit/CreateEditCase.tsx +241 -0
  16. package/src/_components/Modal/CreateEdit/DateSelector.tsx +42 -0
  17. package/src/_components/Modal/CreateEdit/EditUserFieldDropdown.tsx +54 -0
  18. package/src/_components/Modal/CreateEdit/EnumDropdown.tsx +54 -0
  19. package/src/_components/Modal/CreateEdit/HearingOfficerDropdown.tsx +48 -0
  20. package/src/_components/Modal/CreateEdit/TextFieldList.tsx +186 -0
  21. package/src/_components/Modal/CreateEdit/ToggleableTextField.tsx +91 -0
  22. package/src/_components/Modal/Modal.css +15 -0
  23. package/src/_components/Modal/Modal.tsx +325 -0
  24. package/src/_components/Modal/ModalActions.tsx +99 -0
  25. package/src/_components/Modal/View/CaseToolbar.tsx +81 -0
  26. package/src/_components/Modal/View/CaseViewer.tsx +237 -0
  27. package/src/_components/Modal/View/DateDetails.tsx +138 -0
  28. package/src/_components/Modal/View/InfoBox.tsx +22 -0
  29. package/src/_components/Modal/View/InfoBoxBtn.css +39 -0
  30. package/src/_components/Modal/View/InfoBoxBtn.tsx +29 -0
  31. package/src/_components/Modal/View/NoticeFileLink.tsx +44 -0
  32. package/src/_components/Shared/FirstSecondChairIcons.tsx +247 -0
  33. package/src/_components/Shared/FormRow.tsx +37 -0
  34. package/src/_components/Shared/MuniDropdown.tsx +94 -0
  35. package/src/_components/Shared/SearchBar.tsx +87 -0
  36. package/src/_components/Toolbar/CaseFilter.tsx +77 -0
  37. package/src/_components/Toolbar/DateTypeFilter.tsx +63 -0
  38. package/src/_components/Toolbar/HearingTypeFilter.tsx +63 -0
  39. package/src/_components/Toolbar/Toolbar.tsx +159 -0
  40. package/src/_components/Toolbar/UserFilter.tsx +105 -0
  41. package/src/_components/Toolbar/ViewFilter.tsx +48 -0
  42. package/src/helpers/cache.ts +89 -0
  43. package/src/helpers/cases.ts +79 -0
  44. package/src/helpers/courtDates.ts +139 -0
  45. package/src/helpers/formatter.ts +16 -0
  46. package/src/helpers/munis.ts +44 -0
  47. package/src/helpers/people.ts +46 -0
  48. package/src/index.ts +2 -0
  49. package/src/types.ts +129 -0
  50. package/tsconfig.app.json +32 -0
  51. package/tsconfig.json +4 -0
  52. package/tsconfig.node.json +30 -0
  53. package/vite.config.ts +27 -0
@@ -0,0 +1,237 @@
1
+ import { useEffect, useMemo, useState, memo } from 'react';
2
+ import type { CalendarFilterCtx, Case } from '@/types';
3
+ import Box from '@mui/material/Box';
4
+ import Stack from '@mui/material/Stack';
5
+ import Typography from '@mui/material/Typography';
6
+ import { formatEvidence } from '@/helpers/formatter';
7
+ import CircularProgress from '@mui/material/CircularProgress';
8
+ import { DataGrid, type GridColDef } from '@mui/x-data-grid';
9
+ import CaseToolbar from './CaseToolbar';
10
+ import { isCaseMissingEvidence, isCaseSettled } from '@/helpers/cases';
11
+ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
12
+ import CancelIcon from '@mui/icons-material/Cancel';
13
+
14
+ const CaseViewer = memo(function CaseViewer({
15
+ cases,
16
+ isFetchingCases,
17
+ filterCtx,
18
+ setFilterCtx,
19
+ setClickedCase,
20
+ isVillage,
21
+ }: {
22
+ cases: Case[];
23
+ isFetchingCases: boolean;
24
+ filterCtx: CalendarFilterCtx;
25
+ setFilterCtx: (ctx: CalendarFilterCtx) => void;
26
+ setClickedCase: (caseItem: Case | null) => void;
27
+ isVillage: boolean;
28
+ }) {
29
+ const columns: GridColDef[] = [
30
+ {
31
+ field: 'scarIndexNumber',
32
+ headerName: 'Index',
33
+ width: 120,
34
+ disableColumnMenu: true,
35
+ },
36
+ {
37
+ field: 'owner',
38
+ headerName: 'Owner',
39
+ width: 250,
40
+ disableColumnMenu: true,
41
+ renderCell: (params) => {
42
+ if (params.value === 'Loading...') {
43
+ return <CircularProgress size={18} sx={{ color: 'var(--text)' }} />;
44
+ }
45
+ return params.value;
46
+ },
47
+ },
48
+ {
49
+ field: 'address',
50
+ headerName: 'Address',
51
+ width: 175,
52
+ disableColumnMenu: true,
53
+ renderCell: (params) => {
54
+ if (params.value === 'Loading...') {
55
+ return <CircularProgress size={18} sx={{ color: 'var(--text)' }} />;
56
+ }
57
+ return params.value;
58
+ },
59
+ },
60
+ {
61
+ field: 'reviewed',
62
+ headerName: 'Reviewed',
63
+ width: 100,
64
+ disableColumnMenu: true,
65
+ renderCell: (params) => {
66
+ const isReviewed = params.value === 'Yes';
67
+ return (
68
+ <Box display='flex' alignItems='center' justifyContent='center' width='100%' mt={1.7}>
69
+ {isReviewed ? (
70
+ <CheckCircleIcon sx={{ color: '#4caf50', fontSize: 24 }} />
71
+ ) : (
72
+ <CancelIcon sx={{ color: '#f44336', fontSize: 24 }} />
73
+ )}
74
+ </Box>
75
+ );
76
+ },
77
+ },
78
+ {
79
+ field: 'evidence',
80
+ headerName: 'Evidence',
81
+ width: 100,
82
+ disableColumnMenu: true,
83
+ },
84
+ {
85
+ field: 'determination',
86
+ headerName: 'Determination',
87
+ width: 120,
88
+ disableColumnMenu: true,
89
+ },
90
+ {
91
+ field: 'pid',
92
+ headerName: 'PID',
93
+ width: 60,
94
+ disableColumnMenu: true,
95
+ sortable: false,
96
+ filterable: false,
97
+ renderCell: (params) => {
98
+ const pid = params.row.pid;
99
+ const handleCopy = (e: React.MouseEvent) => {
100
+ e.stopPropagation();
101
+ if (navigator && navigator.clipboard) {
102
+ navigator.clipboard.writeText(pid);
103
+ }
104
+ };
105
+ return (
106
+ <button
107
+ onClick={handleCopy}
108
+ style={{
109
+ cursor: 'pointer',
110
+ padding: '2px 8px',
111
+ border: '1px solid #ccc',
112
+ borderRadius: 4,
113
+ background: '#f5f5f5',
114
+ }}
115
+ title='Copy PID'
116
+ >
117
+ Copy
118
+ </button>
119
+ );
120
+ },
121
+ },
122
+ ];
123
+
124
+ const rows = useMemo(() => {
125
+ if (cases.length <= 0) return [];
126
+ const filteredCases = cases.filter((caseItem) => {
127
+ // filter by unsettled
128
+ if (filterCtx.showOnlyUnsettled) {
129
+ if (isCaseSettled(caseItem, isVillage)) {
130
+ return false;
131
+ }
132
+ }
133
+ // filter by unreviewed
134
+ if (filterCtx.showOnlyUnreviewed) {
135
+ if (caseItem.DateCompleted) {
136
+ return false;
137
+ }
138
+ }
139
+ // filter by without evidence
140
+ if (filterCtx.showOnlyWithoutEvidence) {
141
+ if (!isCaseMissingEvidence(caseItem)) {
142
+ return false; // hide cases that have evidence
143
+ }
144
+ }
145
+ // filter by search term
146
+ if (filterCtx.searchTerm) {
147
+ const term = filterCtx.searchTerm.toLowerCase();
148
+ const owner = caseItem.property_data?.PropertyOwnerFull.toLowerCase() || '';
149
+ const address = caseItem.property_data?.Address.toLowerCase() || '';
150
+ const pid = caseItem.ParcelID.toLowerCase();
151
+ const scarid = caseItem.SCARIndexNumber.toLowerCase();
152
+ if (!owner.includes(term) && !address.includes(term) && !pid.includes(term) && !scarid.includes(term)) {
153
+ return false;
154
+ }
155
+ }
156
+ return true;
157
+ });
158
+ return filteredCases.map((caseItem, index) => ({
159
+ id: index,
160
+ scarIndexNumber: isVillage ? caseItem.VillageSCARIndexNumber : caseItem.SCARIndexNumber,
161
+ owner: caseItem.property_data?.PropertyOwnerFull || 'Loading...',
162
+ reviewed: caseItem.DateCompleted ? 'Yes' : 'No',
163
+ address: caseItem.property_data?.Address || 'Loading...',
164
+ evidence: caseItem.evidence ? formatEvidence(caseItem.evidence) : 'N/A',
165
+ determination: getActionDetail(caseItem),
166
+ pid: caseItem.ParcelID || 'N/A',
167
+ _case: caseItem,
168
+ }));
169
+ }, [cases, filterCtx]);
170
+
171
+ function getActionDetail(caseItem: Case): string {
172
+ if (!isVillage) {
173
+ return caseItem.SCARDeterminationAction;
174
+ } else if (isVillage) {
175
+ return caseItem.VillageSCARDeterminationAction;
176
+ }
177
+ return '';
178
+ }
179
+
180
+ // For height animation
181
+ const [tableOpen, setTableOpen] = useState(false);
182
+ useEffect(() => {
183
+ setTableOpen(!isFetchingCases && cases.length > 0);
184
+ }, [isFetchingCases, cases.length]);
185
+
186
+ return (
187
+ <Box justifyContent={'center'} width={'100%'} mt={2}>
188
+ {cases.length === 0 && !isFetchingCases && <Typography>No cases associated with this date.</Typography>}
189
+ {isFetchingCases && (
190
+ <Box display='flex' justifyContent='center' alignItems='center'>
191
+ <Typography>Loading cases...</Typography>
192
+ <CircularProgress size={40} sx={{ color: 'var(--text)' }} />
193
+ </Box>
194
+ )}
195
+ <div
196
+ className='case-table-anim'
197
+ style={{
198
+ maxHeight: tableOpen ? 1000 : 0,
199
+ overflow: 'hidden',
200
+ transition: 'max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
201
+ }}
202
+ >
203
+ {!isFetchingCases && cases.length > 0 && (
204
+ <Stack>
205
+ <Box mb={2}>
206
+ <CaseToolbar filterCtx={filterCtx} setFilterCtx={setFilterCtx} />
207
+ </Box>
208
+ <Box mx={1} mb={1} justifyContent={'center'} alignItems={'center'} display='flex'>
209
+ <Typography variant='body1' fontStyle={''}>
210
+ {rows.length} case{rows.length !== 1 ? 's' : ''} found, click a row to view additional
211
+ case details.
212
+ </Typography>
213
+ </Box>
214
+ <DataGrid
215
+ autoHeight
216
+ rows={rows}
217
+ columns={columns}
218
+ hideFooter
219
+ onRowClick={(params) => setClickedCase(params.row._case)}
220
+ sx={{
221
+ '& .MuiDataGrid-row': {
222
+ cursor: 'pointer',
223
+ transition: 'background-color 0.2s ease-in-out',
224
+ '&:hover': {
225
+ backgroundColor: 'var(--fc-today-bg-color) !important',
226
+ },
227
+ },
228
+ }}
229
+ />
230
+ </Stack>
231
+ )}
232
+ </div>
233
+ </Box>
234
+ );
235
+ });
236
+
237
+ export default CaseViewer;
@@ -0,0 +1,138 @@
1
+ import { HearingType, type Case, type CourtDate } from '../../../types';
2
+ import Box from '@mui/material/Box';
3
+ import Typography from '@mui/material/Typography';
4
+ import FormRow from '../../Shared/FormRow';
5
+ import InfoBox from './InfoBox';
6
+ import Stack from '@mui/material/Stack';
7
+ import { allUsers } from '@/helpers/people';
8
+ import { useEffect, useState } from 'react';
9
+ import InfoBoxBtn from './InfoBoxBtn';
10
+ import SnoozeIcon from '@mui/icons-material/Snooze';
11
+ import NoticeFileLink from './NoticeFileLink';
12
+ import { snoozeUploadDeadline } from '@/helpers/courtDates';
13
+ import { allMuniNames, getTownshipName } from '@/helpers/munis';
14
+
15
+ export default function DateDetails({
16
+ selectedCourtDate,
17
+ apiKey,
18
+ }: {
19
+ selectedCourtDate: CourtDate | null;
20
+ apiKey: string;
21
+ }) {
22
+ const [firstChairName, setFirstChairName] = useState<string>('-');
23
+ const [secondChairName, setSecondChairName] = useState<string>('-');
24
+
25
+ useEffect(() => {
26
+ if (!selectedCourtDate) {
27
+ setFirstChairName('-');
28
+ return;
29
+ }
30
+ if (selectedCourtDate.FirstChair) {
31
+ const user = allUsers[selectedCourtDate.FirstChair];
32
+ setFirstChairName(user ? `${user.UserFirstName} ${user.UserLastName}` : '-');
33
+ } else {
34
+ setFirstChairName('-');
35
+ }
36
+ if (selectedCourtDate.SecondChair) {
37
+ const user = allUsers[selectedCourtDate.SecondChair];
38
+ setSecondChairName(user ? `${user.UserFirstName} ${user.UserLastName}` : '-');
39
+ } else {
40
+ setSecondChairName('-');
41
+ }
42
+ }, [selectedCourtDate]);
43
+
44
+ function formatDate(date: Date | null | undefined): string {
45
+ if (!date) return '-';
46
+ return new Intl.DateTimeFormat('en-US', {
47
+ year: 'numeric',
48
+ month: '2-digit',
49
+ day: '2-digit',
50
+ }).format(new Date(date));
51
+ }
52
+
53
+ // biome-ignore lint/correctness/noUnusedVariables: may be used in the future
54
+ async function handleSnoozeClick() {
55
+ if (!selectedCourtDate || !selectedCourtDate.UploadDeadline) return;
56
+ const newDate = new Date(new Date(selectedCourtDate.UploadDeadline).getTime() + 24 * 60 * 60 * 1000);
57
+ const res = window.confirm(
58
+ `Snooze upload deadline by 1 business day? \nThe new date will be on ${formatDate(newDate)}`,
59
+ );
60
+ if (res) {
61
+ await snoozeUploadDeadline(selectedCourtDate.CourtDateID, apiKey);
62
+ }
63
+ }
64
+
65
+ return (
66
+ <Box className='themed'>
67
+ {selectedCourtDate && (
68
+ <Stack spacing={0} direction='column'>
69
+ <Box>
70
+ <Typography variant='h6' align='center' gutterBottom>
71
+ {`${getTownshipName(selectedCourtDate.MuniCode) || 'Unknown Municipality'} (${selectedCourtDate.MuniCode}) ${selectedCourtDate.FirstChair || selectedCourtDate.SecondChair ? '' : '- Unassigned'}`}
72
+ </Typography>
73
+ </Box>
74
+ <FormRow gap={7}>
75
+ <InfoBox label='First Chair' info={firstChairName} color='#2196f3' />
76
+ <InfoBox label='Second Chair' info={secondChairName} color='#00bcd4' />
77
+ </FormRow>
78
+ <FormRow gap={3}>
79
+ <InfoBox label='Court Date' info={formatDate(selectedCourtDate.CourtDate)} color='#ff9800' />
80
+ <InfoBox
81
+ label='Adjournment Date'
82
+ info={formatDate(selectedCourtDate.AdjournmentDate)}
83
+ color='#9c27b0'
84
+ />
85
+ <InfoBox
86
+ label='Upload Due Date'
87
+ info={formatDate(selectedCourtDate.UploadDeadline)}
88
+ color='#f44336'
89
+ />
90
+ </FormRow>
91
+ <FormRow gap={7}>
92
+ <InfoBox label='Status' info={selectedCourtDate.Lifecycle || '-'} color='#4caf50' />
93
+ </FormRow>
94
+ <NoticeFileLink fileKey={selectedCourtDate.NoticeFile} />
95
+ <Stack mt={2} spacing={1}>
96
+ <Typography variant='body1'>{selectedCourtDate.Notes || 'No notes provided.'}</Typography>
97
+ <Typography variant='body2' sx={{ opacity: 0.85 }}>
98
+ <span>{`The hearing is `}</span>
99
+ <strong>
100
+ {selectedCourtDate.Type === HearingType.VIRTUAL
101
+ ? 'virtual'
102
+ : selectedCourtDate.Type === HearingType.IN_PERSON
103
+ ? 'in person'
104
+ : 'of unknown type'}
105
+ </strong>
106
+ <span>{` and is scheduled for `}</span>
107
+ <strong>{selectedCourtDate.HearingTime}</strong>
108
+ <span>{`.`}</span>
109
+ <br />
110
+ <span>{`The hearing officer is `}</span>
111
+ <strong>{selectedCourtDate.HearingOfficer || 'Not Assigned'}</strong>
112
+ <span>{`.`}</span>
113
+ <br />
114
+ {selectedCourtDate.Type === HearingType.VIRTUAL && (
115
+ <>
116
+ <span>{`The virtual hearing link is `}</span>
117
+ {selectedCourtDate.HearingLink ? (
118
+ <a
119
+ href={selectedCourtDate.HearingLink}
120
+ target='_blank'
121
+ rel='noopener noreferrer'
122
+ style={{ fontWeight: 'bold' }}
123
+ >
124
+ here
125
+ </a>
126
+ ) : (
127
+ <strong>No Link Provided</strong>
128
+ )}
129
+ <span>{`.`}</span>
130
+ </>
131
+ )}
132
+ </Typography>
133
+ </Stack>
134
+ </Stack>
135
+ )}
136
+ </Box>
137
+ );
138
+ }
@@ -0,0 +1,22 @@
1
+ import Stack from '@mui/material/Stack';
2
+ import Typography from '@mui/material/Typography';
3
+
4
+ export default function InfoBox({ label, info, color }: { label: string; info: string; color?: string }) {
5
+ return (
6
+ <Stack
7
+ spacing={0}
8
+ direction='column'
9
+ alignItems='center'
10
+ justifyContent='center'
11
+ border={'2px solid var(--fc-border-color)'}
12
+ p={1}
13
+ borderRadius={2}
14
+ width={'100%'}
15
+ >
16
+ <Typography variant='h5' color={`${color} !important`}>
17
+ {info}
18
+ </Typography>
19
+ <Typography variant='subtitle1'>{label}</Typography>
20
+ </Stack>
21
+ );
22
+ }
@@ -0,0 +1,39 @@
1
+ .info-box-btn {
2
+ cursor: pointer;
3
+ width: 100%;
4
+ display: inherit;
5
+ position: relative;
6
+ }
7
+
8
+ .info-box-btn > .info-box-btn-cover {
9
+ position: absolute;
10
+ top: 0%;
11
+ width: 5%;
12
+ right: 0;
13
+ height: 100%;
14
+ background-color: var(--text) !important;
15
+ color: var(--text);
16
+ z-index: 99999;
17
+ border-top-right-radius: 8px;
18
+ border-bottom-right-radius: 8px;
19
+ overflow: hidden;
20
+ display: flex;
21
+ justify-content: center;
22
+ align-items: center;
23
+ opacity: 0.7;
24
+ transition:
25
+ background-color 0.3s,
26
+ height 0.3s,
27
+ width 0.3s,
28
+ top 0.3s,
29
+ border-radius 0.3s,
30
+ opacity 0.3s;
31
+ }
32
+ .info-box-btn:hover > .info-box-btn-cover {
33
+ height: 100%;
34
+ width: 100%;
35
+ background-color: var(--fc-today-bg-color) !important;
36
+ top: 0;
37
+ border-radius: 8px;
38
+ opacity: 1;
39
+ }
@@ -0,0 +1,29 @@
1
+ import Typography from '@mui/material/Typography';
2
+ import InfoBox from './InfoBox';
3
+ import './InfoBoxBtn.css';
4
+ import Stack from '@mui/material/Stack';
5
+ import Box from '@mui/material/Box';
6
+
7
+ export default function InfoBoxBtn({
8
+ label,
9
+ info,
10
+ btnLabel,
11
+ btnIcon,
12
+ onBtnClick,
13
+ }: {
14
+ label: string;
15
+ info: string;
16
+ btnLabel?: string;
17
+ btnIcon?: React.ReactNode;
18
+ onBtnClick?: () => void;
19
+ }) {
20
+ return (
21
+ <Box className='info-box-btn' onClick={onBtnClick}>
22
+ <Stack className='info-box-btn-cover' direction={'column'}>
23
+ <Typography variant={'h6'}>{btnLabel}</Typography>
24
+ {btnIcon}
25
+ </Stack>
26
+ <InfoBox label={label} info={info} />
27
+ </Box>
28
+ );
29
+ }
@@ -0,0 +1,44 @@
1
+ import Box from '@mui/material/Box';
2
+ import Typography from '@mui/material/Typography';
3
+ import DescriptionIcon from '@mui/icons-material/Description';
4
+ import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
5
+
6
+ const S3_BASE_URL = 'https://aventine-court-notices.s3.us-east-1.amazonaws.com';
7
+
8
+ export default function NoticeFileLink({ fileKey }: { fileKey: string | null }) {
9
+ const hasFile = Boolean(fileKey);
10
+
11
+ function handleClick() {
12
+ if (!fileKey) return;
13
+ const url = fileKey.startsWith('http') ? fileKey : `${S3_BASE_URL}/${fileKey}`;
14
+ window.open(url, '_blank', 'noopener,noreferrer');
15
+ }
16
+
17
+ return (
18
+ <Box
19
+ mt={1}
20
+ mb={1}
21
+ display='flex'
22
+ alignItems='center'
23
+ justifyContent='center'
24
+ gap={1}
25
+ border='2px solid var(--fc-border-color)'
26
+ borderRadius={2}
27
+ p={1.5}
28
+ sx={{
29
+ cursor: hasFile ? 'pointer' : 'default',
30
+ opacity: hasFile ? 1 : 0.5,
31
+ transition: 'opacity 0.2s',
32
+ '&:hover': hasFile ? { opacity: 0.8 } : undefined,
33
+ }}
34
+ onClick={handleClick}
35
+ >
36
+ {hasFile ? (
37
+ <DescriptionIcon sx={{ color: '#66bb6a' }} />
38
+ ) : (
39
+ <InsertDriveFileOutlinedIcon sx={{ color: 'text.secondary' }} />
40
+ )}
41
+ <Typography variant='subtitle1'>{hasFile ? 'View Notice File' : 'No Notice File'}</Typography>
42
+ </Box>
43
+ );
44
+ }