@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.
@@ -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;