@snapdragonsnursery/react-components 1.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/README.md +63 -0
- package/package.json +34 -0
- package/src/AuthButtons.jsx +320 -0
- package/src/ChildSearchDemo.jsx +478 -0
- package/src/ChildSearchModal.jsx +787 -0
- package/src/ThemeToggle.jsx +83 -0
- package/src/index.js +4 -0
- package/src/telemetry.js +49 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import { useMsal } from "@azure/msal-react";
|
|
3
|
+
import { trackEvent } from "./telemetry";
|
|
4
|
+
|
|
5
|
+
const ChildSearchModal = ({
|
|
6
|
+
isOpen,
|
|
7
|
+
onClose,
|
|
8
|
+
onSelect,
|
|
9
|
+
title = "Search Children",
|
|
10
|
+
siteId = null,
|
|
11
|
+
siteIds = null, // Array of site IDs for multi-site search
|
|
12
|
+
sites = null, // Array of site objects from useUserSites hook
|
|
13
|
+
activeOnly = true,
|
|
14
|
+
status = null, // "active", "inactive", or "all" (overrides activeOnly)
|
|
15
|
+
dobFrom = null, // Date of birth from (YYYY-MM-DD)
|
|
16
|
+
dobTo = null, // Date of birth to (YYYY-MM-DD)
|
|
17
|
+
ageFrom = null, // Age in months from
|
|
18
|
+
ageTo = null, // Age in months to
|
|
19
|
+
sortBy = "last_name", // "last_name", "first_name", "date_of_birth", "site_name", "full_name"
|
|
20
|
+
sortOrder = "asc", // "asc" or "desc"
|
|
21
|
+
applicationContext = "child-search",
|
|
22
|
+
bypassPermissions = false,
|
|
23
|
+
className = "",
|
|
24
|
+
showAdvancedFilters = false, // Whether to show advanced filter options
|
|
25
|
+
showSiteFilter = true, // Whether to show site filter dropdown
|
|
26
|
+
multiSelect = false, // Enable multiple child selection
|
|
27
|
+
maxSelections = null, // Maximum number of children that can be selected (null = unlimited)
|
|
28
|
+
selectedChildren = [], // Array of already selected children (for multi-select mode)
|
|
29
|
+
}) => {
|
|
30
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
31
|
+
const [children, setChildren] = useState([]);
|
|
32
|
+
const [loading, setLoading] = useState(false);
|
|
33
|
+
const [error, setError] = useState(null);
|
|
34
|
+
const [pagination, setPagination] = useState({
|
|
35
|
+
page: 1,
|
|
36
|
+
pageSize: 20,
|
|
37
|
+
totalCount: 0,
|
|
38
|
+
totalPages: 0,
|
|
39
|
+
hasNextPage: false,
|
|
40
|
+
hasPreviousPage: false,
|
|
41
|
+
});
|
|
42
|
+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
|
43
|
+
const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] =
|
|
44
|
+
useState(showAdvancedFilters);
|
|
45
|
+
const [advancedFilters, setAdvancedFilters] = useState({
|
|
46
|
+
status: status || (activeOnly ? "active" : "all"),
|
|
47
|
+
selectedSiteId: siteId || "",
|
|
48
|
+
dobFrom: dobFrom || "",
|
|
49
|
+
dobTo: dobTo || "",
|
|
50
|
+
ageFrom: ageFrom || "",
|
|
51
|
+
ageTo: ageTo || "",
|
|
52
|
+
sortBy: sortBy,
|
|
53
|
+
sortOrder: sortOrder,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// State for multi-select mode
|
|
57
|
+
const [selectedChildrenState, setSelectedChildrenState] = useState(
|
|
58
|
+
selectedChildren || []
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const { instance, accounts } = useMsal();
|
|
62
|
+
const modalRef = useRef();
|
|
63
|
+
const searchInputRef = useRef();
|
|
64
|
+
|
|
65
|
+
// Debounce search term
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
setDebouncedSearchTerm(searchTerm);
|
|
69
|
+
setPagination((prev) => ({ ...prev, page: 1 })); // Reset to first page on new search
|
|
70
|
+
}, 300);
|
|
71
|
+
|
|
72
|
+
return () => clearTimeout(timer);
|
|
73
|
+
}, [searchTerm]);
|
|
74
|
+
|
|
75
|
+
// Close modal when clicking outside
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const handleClickOutside = (event) => {
|
|
78
|
+
if (modalRef.current && !modalRef.current.contains(event.target)) {
|
|
79
|
+
onClose();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (isOpen) {
|
|
84
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
85
|
+
// Focus search input when modal opens
|
|
86
|
+
if (searchInputRef.current) {
|
|
87
|
+
searchInputRef.current.focus();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
93
|
+
};
|
|
94
|
+
}, [isOpen, onClose]);
|
|
95
|
+
|
|
96
|
+
// Search children
|
|
97
|
+
const searchChildren = useCallback(async () => {
|
|
98
|
+
if (!instance || !accounts[0]) {
|
|
99
|
+
setError("Authentication required");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setLoading(true);
|
|
104
|
+
setError(null);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// Get access token
|
|
108
|
+
const response = await instance.acquireTokenSilent({
|
|
109
|
+
scopes: ["api://your-api-scope/.default"], // Update with your actual API scope
|
|
110
|
+
account: accounts[0],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Build query parameters
|
|
114
|
+
const params = new URLSearchParams({
|
|
115
|
+
entra_id: accounts[0].localAccountId,
|
|
116
|
+
search_term: debouncedSearchTerm,
|
|
117
|
+
page: pagination.page,
|
|
118
|
+
page_size: pagination.pageSize,
|
|
119
|
+
application_context: applicationContext,
|
|
120
|
+
bypass_permissions: bypassPermissions.toString(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Handle site filtering
|
|
124
|
+
if (siteIds && siteIds.length > 0) {
|
|
125
|
+
params.append("site_ids", siteIds.join(","));
|
|
126
|
+
} else if (advancedFilters.selectedSiteId) {
|
|
127
|
+
params.append("site_id", advancedFilters.selectedSiteId.toString());
|
|
128
|
+
} else if (siteId) {
|
|
129
|
+
params.append("site_id", siteId.toString());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Handle status filtering
|
|
133
|
+
if (advancedFilters.status && advancedFilters.status !== "all") {
|
|
134
|
+
params.append("status", advancedFilters.status);
|
|
135
|
+
} else if (activeOnly) {
|
|
136
|
+
params.append("active_only", "true");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Add date of birth filters
|
|
140
|
+
if (advancedFilters.dobFrom) {
|
|
141
|
+
params.append("dob_from", advancedFilters.dobFrom);
|
|
142
|
+
}
|
|
143
|
+
if (advancedFilters.dobTo) {
|
|
144
|
+
params.append("dob_to", advancedFilters.dobTo);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add age filters
|
|
148
|
+
if (advancedFilters.ageFrom) {
|
|
149
|
+
params.append("age_from", advancedFilters.ageFrom);
|
|
150
|
+
}
|
|
151
|
+
if (advancedFilters.ageTo) {
|
|
152
|
+
params.append("age_to", advancedFilters.ageTo);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Add sorting
|
|
156
|
+
params.append("sort_by", advancedFilters.sortBy);
|
|
157
|
+
params.append("sort_order", advancedFilters.sortOrder);
|
|
158
|
+
|
|
159
|
+
// Make API call
|
|
160
|
+
const apiResponse = await fetch(
|
|
161
|
+
`${
|
|
162
|
+
process.env.REACT_APP_API_BASE_URL ||
|
|
163
|
+
"https://your-function-app.azurewebsites.net"
|
|
164
|
+
}/api/search-children?${params}`,
|
|
165
|
+
{
|
|
166
|
+
headers: {
|
|
167
|
+
Authorization: `Bearer ${response.accessToken}`,
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (!apiResponse.ok) {
|
|
174
|
+
throw new Error(`API request failed: ${apiResponse.status}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const data = await apiResponse.json();
|
|
178
|
+
|
|
179
|
+
if (data.success) {
|
|
180
|
+
setChildren(data.data.children);
|
|
181
|
+
setPagination(data.data.pagination);
|
|
182
|
+
trackEvent("child_search_success", {
|
|
183
|
+
searchTerm: debouncedSearchTerm,
|
|
184
|
+
resultCount: data.data.children.length,
|
|
185
|
+
totalCount: data.data.pagination.totalCount,
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
throw new Error(data.error || "Search failed");
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error("Error searching children:", err);
|
|
192
|
+
setError(err.message);
|
|
193
|
+
trackEvent("child_search_error", {
|
|
194
|
+
error: err.message,
|
|
195
|
+
searchTerm: debouncedSearchTerm,
|
|
196
|
+
});
|
|
197
|
+
} finally {
|
|
198
|
+
setLoading(false);
|
|
199
|
+
}
|
|
200
|
+
}, [
|
|
201
|
+
instance,
|
|
202
|
+
accounts,
|
|
203
|
+
debouncedSearchTerm,
|
|
204
|
+
pagination.page,
|
|
205
|
+
pagination.pageSize,
|
|
206
|
+
siteId,
|
|
207
|
+
siteIds,
|
|
208
|
+
activeOnly,
|
|
209
|
+
advancedFilters,
|
|
210
|
+
applicationContext,
|
|
211
|
+
bypassPermissions,
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
// Search when debounced term changes
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (isOpen) {
|
|
217
|
+
searchChildren();
|
|
218
|
+
}
|
|
219
|
+
}, [debouncedSearchTerm, pagination.page, isOpen, searchChildren]);
|
|
220
|
+
|
|
221
|
+
// Reset selection when modal opens (for multi-select mode)
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (isOpen && multiSelect) {
|
|
224
|
+
setSelectedChildrenState(selectedChildren || []);
|
|
225
|
+
}
|
|
226
|
+
}, [isOpen, multiSelect, selectedChildren]);
|
|
227
|
+
|
|
228
|
+
const handleChildSelect = (child) => {
|
|
229
|
+
if (multiSelect) {
|
|
230
|
+
// Multi-select mode
|
|
231
|
+
const isAlreadySelected = selectedChildrenState.some(
|
|
232
|
+
(selected) => selected.child_id === child.child_id
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (isAlreadySelected) {
|
|
236
|
+
// Remove child from selection
|
|
237
|
+
const updatedSelection = selectedChildrenState.filter(
|
|
238
|
+
(selected) => selected.child_id !== child.child_id
|
|
239
|
+
);
|
|
240
|
+
setSelectedChildrenState(updatedSelection);
|
|
241
|
+
trackEvent("child_deselected", {
|
|
242
|
+
childId: child.child_id,
|
|
243
|
+
childName: child.full_name,
|
|
244
|
+
siteId: child.site_id,
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
// Add child to selection (check max limit)
|
|
248
|
+
if (maxSelections && selectedChildrenState.length >= maxSelections) {
|
|
249
|
+
// Could show a toast/alert here
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const updatedSelection = [...selectedChildrenState, child];
|
|
253
|
+
setSelectedChildrenState(updatedSelection);
|
|
254
|
+
trackEvent("child_selected", {
|
|
255
|
+
childId: child.child_id,
|
|
256
|
+
childName: child.full_name,
|
|
257
|
+
siteId: child.site_id,
|
|
258
|
+
multiSelect: true,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
// Single select mode
|
|
263
|
+
trackEvent("child_selected", {
|
|
264
|
+
childId: child.child_id,
|
|
265
|
+
childName: child.full_name,
|
|
266
|
+
siteId: child.site_id,
|
|
267
|
+
multiSelect: false,
|
|
268
|
+
});
|
|
269
|
+
onSelect(child);
|
|
270
|
+
onClose();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const handlePageChange = (newPage) => {
|
|
275
|
+
setPagination((prev) => ({ ...prev, page: newPage }));
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const handleConfirmSelection = () => {
|
|
279
|
+
trackEvent("multi_select_confirmed", {
|
|
280
|
+
selectedCount: selectedChildrenState.length,
|
|
281
|
+
applicationContext,
|
|
282
|
+
});
|
|
283
|
+
onSelect(selectedChildrenState);
|
|
284
|
+
onClose();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const handleCancelSelection = () => {
|
|
288
|
+
trackEvent("multi_select_cancelled", {
|
|
289
|
+
applicationContext,
|
|
290
|
+
});
|
|
291
|
+
setSelectedChildrenState([]);
|
|
292
|
+
onClose();
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const isChildSelected = (child) => {
|
|
296
|
+
return selectedChildrenState.some(
|
|
297
|
+
(selected) => selected.child_id === child.child_id
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const formatAge = (years, months) => {
|
|
302
|
+
if (years && months) {
|
|
303
|
+
return `${years}y ${months}m`;
|
|
304
|
+
} else if (years) {
|
|
305
|
+
return `${years}y`;
|
|
306
|
+
} else if (months) {
|
|
307
|
+
return `${months}m`;
|
|
308
|
+
}
|
|
309
|
+
return "";
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const formatDate = (dateString) => {
|
|
313
|
+
if (!dateString) return "";
|
|
314
|
+
return new Date(dateString).toLocaleDateString("en-GB");
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
if (!isOpen) return null;
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
321
|
+
<div
|
|
322
|
+
ref={modalRef}
|
|
323
|
+
className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col ${className}`}
|
|
324
|
+
>
|
|
325
|
+
{/* Header */}
|
|
326
|
+
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
|
327
|
+
<div className="flex items-center space-x-3">
|
|
328
|
+
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
329
|
+
{title}
|
|
330
|
+
</h2>
|
|
331
|
+
{multiSelect && (
|
|
332
|
+
<span className="px-2 py-1 text-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
|
|
333
|
+
{selectedChildrenState.length} selected
|
|
334
|
+
{maxSelections && ` / ${maxSelections}`}
|
|
335
|
+
</span>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
<div className="flex items-center space-x-2">
|
|
339
|
+
{multiSelect && selectedChildrenState.length > 0 && (
|
|
340
|
+
<>
|
|
341
|
+
<button
|
|
342
|
+
onClick={handleConfirmSelection}
|
|
343
|
+
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm"
|
|
344
|
+
>
|
|
345
|
+
Confirm ({selectedChildrenState.length})
|
|
346
|
+
</button>
|
|
347
|
+
<button
|
|
348
|
+
onClick={handleCancelSelection}
|
|
349
|
+
className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors text-sm"
|
|
350
|
+
>
|
|
351
|
+
Cancel
|
|
352
|
+
</button>
|
|
353
|
+
</>
|
|
354
|
+
)}
|
|
355
|
+
<button
|
|
356
|
+
onClick={onClose}
|
|
357
|
+
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
358
|
+
>
|
|
359
|
+
<svg
|
|
360
|
+
className="w-6 h-6"
|
|
361
|
+
fill="none"
|
|
362
|
+
stroke="currentColor"
|
|
363
|
+
viewBox="0 0 24 24"
|
|
364
|
+
>
|
|
365
|
+
<path
|
|
366
|
+
strokeLinecap="round"
|
|
367
|
+
strokeLinejoin="round"
|
|
368
|
+
strokeWidth={2}
|
|
369
|
+
d="M6 18L18 6M6 6l12 12"
|
|
370
|
+
/>
|
|
371
|
+
</svg>
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
{/* Search Input */}
|
|
377
|
+
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
378
|
+
<div className="relative">
|
|
379
|
+
<input
|
|
380
|
+
ref={searchInputRef}
|
|
381
|
+
type="text"
|
|
382
|
+
placeholder="Search by name or child ID..."
|
|
383
|
+
value={searchTerm}
|
|
384
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
385
|
+
className="w-full px-4 py-2 pl-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
|
|
386
|
+
/>
|
|
387
|
+
<svg
|
|
388
|
+
className="absolute left-3 top-2.5 w-5 h-5 text-gray-400"
|
|
389
|
+
fill="none"
|
|
390
|
+
stroke="currentColor"
|
|
391
|
+
viewBox="0 0 24 24"
|
|
392
|
+
>
|
|
393
|
+
<path
|
|
394
|
+
strokeLinecap="round"
|
|
395
|
+
strokeLinejoin="round"
|
|
396
|
+
strokeWidth={2}
|
|
397
|
+
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
398
|
+
/>
|
|
399
|
+
</svg>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{/* Advanced Filters Toggle */}
|
|
403
|
+
<div className="mt-4">
|
|
404
|
+
<button
|
|
405
|
+
onClick={() => setIsAdvancedFiltersOpen(!isAdvancedFiltersOpen)}
|
|
406
|
+
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center space-x-1"
|
|
407
|
+
>
|
|
408
|
+
<svg
|
|
409
|
+
className={`w-4 h-4 transition-transform ${
|
|
410
|
+
isAdvancedFiltersOpen ? "rotate-180" : ""
|
|
411
|
+
}`}
|
|
412
|
+
fill="none"
|
|
413
|
+
stroke="currentColor"
|
|
414
|
+
viewBox="0 0 24 24"
|
|
415
|
+
>
|
|
416
|
+
<path
|
|
417
|
+
strokeLinecap="round"
|
|
418
|
+
strokeLinejoin="round"
|
|
419
|
+
strokeWidth={2}
|
|
420
|
+
d="M19 9l-7 7-7-7"
|
|
421
|
+
/>
|
|
422
|
+
</svg>
|
|
423
|
+
<span>Advanced Filters</span>
|
|
424
|
+
</button>
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
{/* Advanced Filters */}
|
|
428
|
+
{isAdvancedFiltersOpen && (
|
|
429
|
+
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
430
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
431
|
+
{/* Status Filter */}
|
|
432
|
+
<div>
|
|
433
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
434
|
+
Status
|
|
435
|
+
</label>
|
|
436
|
+
<select
|
|
437
|
+
value={advancedFilters.status}
|
|
438
|
+
onChange={(e) =>
|
|
439
|
+
setAdvancedFilters((prev) => ({
|
|
440
|
+
...prev,
|
|
441
|
+
status: e.target.value,
|
|
442
|
+
}))
|
|
443
|
+
}
|
|
444
|
+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-600 dark:text-white"
|
|
445
|
+
>
|
|
446
|
+
<option value="all">All Children</option>
|
|
447
|
+
<option value="active">Active Only</option>
|
|
448
|
+
<option value="inactive">Inactive Only</option>
|
|
449
|
+
</select>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
{/* Site Filter */}
|
|
453
|
+
{showSiteFilter && sites && sites.length > 0 && (
|
|
454
|
+
<div>
|
|
455
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
456
|
+
Site
|
|
457
|
+
</label>
|
|
458
|
+
<select
|
|
459
|
+
value={advancedFilters.selectedSiteId}
|
|
460
|
+
onChange={(e) =>
|
|
461
|
+
setAdvancedFilters((prev) => ({
|
|
462
|
+
...prev,
|
|
463
|
+
selectedSiteId: e.target.value,
|
|
464
|
+
}))
|
|
465
|
+
}
|
|
466
|
+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-600 dark:text-white"
|
|
467
|
+
>
|
|
468
|
+
<option value="">All Sites</option>
|
|
469
|
+
{sites.map((site) => (
|
|
470
|
+
<option key={site.site_id} value={site.site_id}>
|
|
471
|
+
{site.site_name}
|
|
472
|
+
</option>
|
|
473
|
+
))}
|
|
474
|
+
</select>
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
|
|
478
|
+
{/* Date of Birth Range */}
|
|
479
|
+
<div>
|
|
480
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
481
|
+
Date of Birth From
|
|
482
|
+
</label>
|
|
483
|
+
<input
|
|
484
|
+
type="date"
|
|
485
|
+
value={advancedFilters.dobFrom}
|
|
486
|
+
onChange={(e) =>
|
|
487
|
+
setAdvancedFilters((prev) => ({
|
|
488
|
+
...prev,
|
|
489
|
+
dobFrom: e.target.value,
|
|
490
|
+
}))
|
|
491
|
+
}
|
|
492
|
+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-600 dark:text-white"
|
|
493
|
+
/>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<div>
|
|
497
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
498
|
+
Date of Birth To
|
|
499
|
+
</label>
|
|
500
|
+
<input
|
|
501
|
+
type="date"
|
|
502
|
+
value={advancedFilters.dobTo}
|
|
503
|
+
onChange={(e) =>
|
|
504
|
+
setAdvancedFilters((prev) => ({
|
|
505
|
+
...prev,
|
|
506
|
+
dobTo: e.target.value,
|
|
507
|
+
}))
|
|
508
|
+
}
|
|
509
|
+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-600 dark:text-white"
|
|
510
|
+
/>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
{/* Age Range (in months) */}
|
|
514
|
+
<div>
|
|
515
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
516
|
+
Age From (months)
|
|
517
|
+
</label>
|
|
518
|
+
<input
|
|
519
|
+
type="number"
|
|
520
|
+
min="0"
|
|
521
|
+
value={advancedFilters.ageFrom}
|
|
522
|
+
onChange={(e) =>
|
|
523
|
+
setAdvancedFilters((prev) => ({
|
|
524
|
+
...prev,
|
|
525
|
+
ageFrom: e.target.value,
|
|
526
|
+
}))
|
|
527
|
+
}
|
|
528
|
+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-600 dark:text-white"
|
|
529
|
+
placeholder="0"
|
|
530
|
+
/>
|
|
531
|
+
</div>
|
|
532
|
+
|
|
533
|
+
<div>
|
|
534
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
535
|
+
Age To (months)
|
|
536
|
+
</label>
|
|
537
|
+
<input
|
|
538
|
+
type="number"
|
|
539
|
+
min="0"
|
|
540
|
+
value={advancedFilters.ageTo}
|
|
541
|
+
onChange={(e) =>
|
|
542
|
+
setAdvancedFilters((prev) => ({
|
|
543
|
+
...prev,
|
|
544
|
+
ageTo: e.target.value,
|
|
545
|
+
}))
|
|
546
|
+
}
|
|
547
|
+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-600 dark:text-white"
|
|
548
|
+
placeholder="60"
|
|
549
|
+
/>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
{/* Sort Options */}
|
|
553
|
+
<div>
|
|
554
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
555
|
+
Sort By
|
|
556
|
+
</label>
|
|
557
|
+
<select
|
|
558
|
+
value={advancedFilters.sortBy}
|
|
559
|
+
onChange={(e) =>
|
|
560
|
+
setAdvancedFilters((prev) => ({
|
|
561
|
+
...prev,
|
|
562
|
+
sortBy: e.target.value,
|
|
563
|
+
}))
|
|
564
|
+
}
|
|
565
|
+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-600 dark:text-white"
|
|
566
|
+
>
|
|
567
|
+
<option value="last_name">Last Name</option>
|
|
568
|
+
<option value="first_name">First Name</option>
|
|
569
|
+
<option value="full_name">Full Name</option>
|
|
570
|
+
<option value="date_of_birth">Date of Birth</option>
|
|
571
|
+
<option value="site_name">Site Name</option>
|
|
572
|
+
</select>
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
<div>
|
|
576
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
577
|
+
Sort Order
|
|
578
|
+
</label>
|
|
579
|
+
<select
|
|
580
|
+
value={advancedFilters.sortOrder}
|
|
581
|
+
onChange={(e) =>
|
|
582
|
+
setAdvancedFilters((prev) => ({
|
|
583
|
+
...prev,
|
|
584
|
+
sortOrder: e.target.value,
|
|
585
|
+
}))
|
|
586
|
+
}
|
|
587
|
+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-600 dark:text-white"
|
|
588
|
+
>
|
|
589
|
+
<option value="asc">Ascending</option>
|
|
590
|
+
<option value="desc">Descending</option>
|
|
591
|
+
</select>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
{/* Clear Filters Button */}
|
|
596
|
+
<div className="mt-4 flex justify-end">
|
|
597
|
+
<button
|
|
598
|
+
onClick={() =>
|
|
599
|
+
setAdvancedFilters({
|
|
600
|
+
status: activeOnly ? "active" : "all",
|
|
601
|
+
selectedSiteId: "",
|
|
602
|
+
dobFrom: "",
|
|
603
|
+
dobTo: "",
|
|
604
|
+
ageFrom: "",
|
|
605
|
+
ageTo: "",
|
|
606
|
+
sortBy: "last_name",
|
|
607
|
+
sortOrder: "asc",
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
|
|
611
|
+
>
|
|
612
|
+
Clear Filters
|
|
613
|
+
</button>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
|
|
619
|
+
{/* Content */}
|
|
620
|
+
<div className="flex-1 overflow-hidden">
|
|
621
|
+
{loading && (
|
|
622
|
+
<div className="flex items-center justify-center p-8">
|
|
623
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
|
624
|
+
</div>
|
|
625
|
+
)}
|
|
626
|
+
|
|
627
|
+
{error && (
|
|
628
|
+
<div className="p-6 text-center">
|
|
629
|
+
<div className="text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
|
630
|
+
{error}
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
)}
|
|
634
|
+
|
|
635
|
+
{!loading && !error && (
|
|
636
|
+
<div className="overflow-y-auto max-h-96">
|
|
637
|
+
{children.length === 0 ? (
|
|
638
|
+
<div className="p-6 text-center text-gray-500 dark:text-gray-400">
|
|
639
|
+
{debouncedSearchTerm
|
|
640
|
+
? "No children found matching your search."
|
|
641
|
+
: "Start typing to search for children."}
|
|
642
|
+
</div>
|
|
643
|
+
) : (
|
|
644
|
+
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
645
|
+
{children.map((child) => {
|
|
646
|
+
const isSelected = isChildSelected(child);
|
|
647
|
+
return (
|
|
648
|
+
<div
|
|
649
|
+
key={child.child_id}
|
|
650
|
+
onClick={() => handleChildSelect(child)}
|
|
651
|
+
className={`p-4 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors ${
|
|
652
|
+
isSelected && multiSelect
|
|
653
|
+
? "bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500"
|
|
654
|
+
: ""
|
|
655
|
+
}`}
|
|
656
|
+
>
|
|
657
|
+
<div className="flex items-center justify-between">
|
|
658
|
+
<div className="flex items-center space-x-3 flex-1">
|
|
659
|
+
{multiSelect && (
|
|
660
|
+
<div className="flex-shrink-0">
|
|
661
|
+
<div
|
|
662
|
+
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
|
663
|
+
isSelected
|
|
664
|
+
? "bg-blue-500 border-blue-500"
|
|
665
|
+
: "border-gray-300 dark:border-gray-600"
|
|
666
|
+
}`}
|
|
667
|
+
>
|
|
668
|
+
{isSelected && (
|
|
669
|
+
<svg
|
|
670
|
+
className="w-3 h-3 text-white"
|
|
671
|
+
fill="currentColor"
|
|
672
|
+
viewBox="0 0 20 20"
|
|
673
|
+
>
|
|
674
|
+
<path
|
|
675
|
+
fillRule="evenodd"
|
|
676
|
+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
677
|
+
clipRule="evenodd"
|
|
678
|
+
/>
|
|
679
|
+
</svg>
|
|
680
|
+
)}
|
|
681
|
+
</div>
|
|
682
|
+
</div>
|
|
683
|
+
)}
|
|
684
|
+
<div className="flex-1">
|
|
685
|
+
<div className="flex items-center space-x-3">
|
|
686
|
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
|
687
|
+
{child.full_name}
|
|
688
|
+
</h3>
|
|
689
|
+
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
690
|
+
ID: {child.child_id}
|
|
691
|
+
</span>
|
|
692
|
+
{!child.is_active && (
|
|
693
|
+
<span className="px-2 py-1 text-xs bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 rounded-full">
|
|
694
|
+
Inactive
|
|
695
|
+
</span>
|
|
696
|
+
)}
|
|
697
|
+
</div>
|
|
698
|
+
<div className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
699
|
+
<span>{child.site_name}</span>
|
|
700
|
+
{child.date_of_birth && (
|
|
701
|
+
<>
|
|
702
|
+
<span className="mx-2">•</span>
|
|
703
|
+
<span>
|
|
704
|
+
DOB: {formatDate(child.date_of_birth)}
|
|
705
|
+
</span>
|
|
706
|
+
</>
|
|
707
|
+
)}
|
|
708
|
+
{(child.age_years || child.age_months) && (
|
|
709
|
+
<>
|
|
710
|
+
<span className="mx-2">•</span>
|
|
711
|
+
<span>
|
|
712
|
+
Age:{" "}
|
|
713
|
+
{formatAge(
|
|
714
|
+
child.age_years,
|
|
715
|
+
child.age_months
|
|
716
|
+
)}
|
|
717
|
+
</span>
|
|
718
|
+
</>
|
|
719
|
+
)}
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
{!multiSelect && (
|
|
724
|
+
<svg
|
|
725
|
+
className="w-5 h-5 text-gray-400"
|
|
726
|
+
fill="none"
|
|
727
|
+
stroke="currentColor"
|
|
728
|
+
viewBox="0 0 24 24"
|
|
729
|
+
>
|
|
730
|
+
<path
|
|
731
|
+
strokeLinecap="round"
|
|
732
|
+
strokeLinejoin="round"
|
|
733
|
+
strokeWidth={2}
|
|
734
|
+
d="M9 5l7 7-7 7"
|
|
735
|
+
/>
|
|
736
|
+
</svg>
|
|
737
|
+
)}
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
);
|
|
741
|
+
})}
|
|
742
|
+
</div>
|
|
743
|
+
)}
|
|
744
|
+
</div>
|
|
745
|
+
)}
|
|
746
|
+
</div>
|
|
747
|
+
|
|
748
|
+
{/* Pagination */}
|
|
749
|
+
{!loading && !error && pagination.totalPages > 1 && (
|
|
750
|
+
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
|
|
751
|
+
<div className="flex items-center justify-between">
|
|
752
|
+
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
753
|
+
Showing {(pagination.page - 1) * pagination.pageSize + 1} to{" "}
|
|
754
|
+
{Math.min(
|
|
755
|
+
pagination.page * pagination.pageSize,
|
|
756
|
+
pagination.totalCount
|
|
757
|
+
)}{" "}
|
|
758
|
+
of {pagination.totalCount} children
|
|
759
|
+
</div>
|
|
760
|
+
<div className="flex space-x-2">
|
|
761
|
+
<button
|
|
762
|
+
onClick={() => handlePageChange(pagination.page - 1)}
|
|
763
|
+
disabled={!pagination.hasPreviousPage}
|
|
764
|
+
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
765
|
+
>
|
|
766
|
+
Previous
|
|
767
|
+
</button>
|
|
768
|
+
<span className="px-3 py-1 text-sm text-gray-600 dark:text-gray-400">
|
|
769
|
+
Page {pagination.page} of {pagination.totalPages}
|
|
770
|
+
</span>
|
|
771
|
+
<button
|
|
772
|
+
onClick={() => handlePageChange(pagination.page + 1)}
|
|
773
|
+
disabled={!pagination.hasNextPage}
|
|
774
|
+
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
775
|
+
>
|
|
776
|
+
Next
|
|
777
|
+
</button>
|
|
778
|
+
</div>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
)}
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
);
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
export default ChildSearchModal;
|