@snapdragonsnursery/react-components 1.1.9 → 1.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapdragonsnursery/react-components",
3
- "version": "1.1.9",
3
+ "version": "1.1.12",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -31,7 +31,7 @@
31
31
  "keywords": [],
32
32
  "author": "",
33
33
  "license": "ISC",
34
- "type": "commonjs",
34
+ "type": "module",
35
35
  "bugs": {
36
36
  "url": "https://github.com/Snapdragons-Nursery/react-components/issues"
37
37
  },
@@ -39,8 +39,13 @@
39
39
  "dependencies": {
40
40
  "@headlessui/react": "^2.2.4",
41
41
  "@popperjs/core": "^2.11.8",
42
+ "@tanstack/react-table": "^8.21.3",
43
+ "class-variance-authority": "^0.7.1",
44
+ "clsx": "^2.1.1",
45
+ "lucide-react": "^0.526.0",
42
46
  "react": "^18.3.1",
43
- "react-dom": "^18.3.1"
47
+ "react-dom": "^18.3.1",
48
+ "tailwind-merge": "^3.3.1"
44
49
  },
45
50
  "peerDependencies": {
46
51
  "@azure/msal-react": ">=1.0.0",
@@ -0,0 +1,821 @@
1
+ // Full page Child Search component with TanStack table and shadcn styling
2
+ // Provides a comprehensive search interface for children with advanced filtering and pagination
3
+
4
+ import React, { useState, useEffect, useCallback } from "react";
5
+ import { useMsal } from "@azure/msal-react";
6
+ import {
7
+ useReactTable,
8
+ getCoreRowModel,
9
+ getSortedRowModel,
10
+ flexRender,
11
+ createColumnHelper,
12
+ } from "@tanstack/react-table";
13
+ import { trackEvent } from "./telemetry";
14
+ import { Button } from "./components/ui/button";
15
+ import { Input } from "./components/ui/input";
16
+ import { Select, SelectOption } from "./components/ui/select";
17
+ import {
18
+ Table,
19
+ TableBody,
20
+ TableCell,
21
+ TableHead,
22
+ TableHeader,
23
+ TableRow,
24
+ } from "./components/ui/table";
25
+ import {
26
+ Pagination,
27
+ PaginationContent,
28
+ PaginationEllipsis,
29
+ PaginationItem,
30
+ PaginationLink,
31
+ PaginationNext,
32
+ PaginationPrevious,
33
+ } from "./components/ui/pagination";
34
+ import {
35
+ MagnifyingGlassIcon,
36
+ FunnelIcon,
37
+ ChevronDownIcon,
38
+ ChevronUpIcon,
39
+ UserIcon,
40
+ MapPinIcon,
41
+ CalendarIcon,
42
+ CheckCircleIcon,
43
+ XCircleIcon,
44
+ } from "@heroicons/react/24/outline";
45
+ import { cn } from "./lib/utils";
46
+
47
+ const ChildSearchPage = ({
48
+ title = "Search Children",
49
+ siteId = null,
50
+ siteIds = null,
51
+ sites = null,
52
+ activeOnly = true,
53
+ status = null,
54
+ dobFrom = null,
55
+ dobTo = null,
56
+ ageFrom = null,
57
+ ageTo = null,
58
+ sortBy = "last_name",
59
+ sortOrder = "asc",
60
+ applicationContext = "child-search",
61
+ bypassPermissions = false,
62
+ onSelect = null, // Optional callback for when a child is selected
63
+ multiSelect = false,
64
+ maxSelections = null,
65
+ selectedChildren = [],
66
+ }) => {
67
+ const [searchTerm, setSearchTerm] = useState("");
68
+ const [children, setChildren] = useState([]);
69
+ const [loading, setLoading] = useState(false);
70
+ const [error, setError] = useState(null);
71
+ const [pagination, setPagination] = useState({
72
+ page: 1,
73
+ pageSize: 20,
74
+ totalCount: 0,
75
+ totalPages: 0,
76
+ hasNextPage: false,
77
+ hasPreviousPage: false,
78
+ });
79
+ const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
80
+ const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState(false);
81
+ const [advancedFilters, setAdvancedFilters] = useState({
82
+ status: status || (activeOnly ? "active" : "all"),
83
+ selectedSiteId: siteId || "",
84
+ dobFrom: dobFrom || "",
85
+ dobTo: dobTo || "",
86
+ ageFrom: ageFrom || "",
87
+ ageTo: ageTo || "",
88
+ sortBy: sortBy,
89
+ sortOrder: sortOrder,
90
+ });
91
+
92
+ // State for multi-select mode
93
+ const [selectedChildrenState, setSelectedChildrenState] = useState(
94
+ selectedChildren || []
95
+ );
96
+
97
+ const { instance, accounts } = useMsal();
98
+
99
+ // Column helper for TanStack table
100
+ const columnHelper = createColumnHelper();
101
+
102
+ // Define table columns
103
+ const columns = [
104
+ // Checkbox column for multi-select
105
+ ...(multiSelect
106
+ ? [
107
+ columnHelper.display({
108
+ id: "select",
109
+ header: ({ table }) => (
110
+ <input
111
+ type="checkbox"
112
+ checked={table.getIsAllPageRowsSelected()}
113
+ onChange={table.getToggleAllPageRowsSelectedHandler()}
114
+ className="rounded border-gray-300"
115
+ />
116
+ ),
117
+ cell: ({ row }) => (
118
+ <input
119
+ type="checkbox"
120
+ checked={row.getIsSelected()}
121
+ onChange={row.getToggleSelectedHandler()}
122
+ className="rounded border-gray-300"
123
+ />
124
+ ),
125
+ size: 50,
126
+ }),
127
+ ]
128
+ : []),
129
+ // Name column
130
+ columnHelper.accessor("full_name", {
131
+ header: "Name",
132
+ cell: ({ row }) => (
133
+ <div className="flex items-center space-x-2">
134
+ <UserIcon className="h-4 w-4 text-gray-400" />
135
+ <div>
136
+ <div className="font-medium">{row.original.full_name}</div>
137
+ <div className="text-sm text-gray-500">
138
+ ID: {row.original.child_id}
139
+ </div>
140
+ </div>
141
+ </div>
142
+ ),
143
+ }),
144
+ // Site column
145
+ columnHelper.accessor("site_name", {
146
+ header: "Site",
147
+ cell: ({ row }) => (
148
+ <div className="flex items-center space-x-2">
149
+ <MapPinIcon className="h-4 w-4 text-gray-400" />
150
+ <span>{row.original.site_name}</span>
151
+ </div>
152
+ ),
153
+ }),
154
+ // Date of Birth column
155
+ columnHelper.accessor("date_of_birth", {
156
+ header: "Date of Birth",
157
+ cell: ({ row }) => (
158
+ <div className="flex items-center space-x-2">
159
+ <CalendarIcon className="h-4 w-4 text-gray-400" />
160
+ <span>
161
+ {row.original.date_of_birth
162
+ ? new Date(row.original.date_of_birth).toLocaleDateString("en-GB")
163
+ : "N/A"}
164
+ </span>
165
+ </div>
166
+ ),
167
+ }),
168
+ // Age column
169
+ columnHelper.accessor("age_years", {
170
+ header: "Age",
171
+ cell: ({ row }) => {
172
+ const { age_years, age_months } = row.original;
173
+ const ageText =
174
+ age_years || age_months
175
+ ? `${age_years || 0}y ${age_months || 0}m`
176
+ : "N/A";
177
+ return (
178
+ <div className="flex items-center space-x-2">
179
+ <span className="text-gray-400 font-mono">#</span>
180
+ <span>{ageText}</span>
181
+ </div>
182
+ );
183
+ },
184
+ }),
185
+ // Status column
186
+ columnHelper.accessor("is_active", {
187
+ header: "Status",
188
+ cell: ({ row }) => (
189
+ <div className="flex items-center space-x-2">
190
+ {row.original.is_active ? (
191
+ <CheckCircleIcon className="h-4 w-4 text-green-500" />
192
+ ) : (
193
+ <XCircleIcon className="h-4 w-4 text-red-500" />
194
+ )}
195
+ <span
196
+ className={cn(
197
+ "px-2 py-1 text-xs rounded-full",
198
+ row.original.is_active
199
+ ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
200
+ : "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
201
+ )}
202
+ >
203
+ {row.original.is_active ? "Active" : "Inactive"}
204
+ </span>
205
+ </div>
206
+ ),
207
+ }),
208
+ ];
209
+
210
+ // Create table instance
211
+ const table = useReactTable({
212
+ data: children,
213
+ columns,
214
+ getCoreRowModel: getCoreRowModel(),
215
+ getSortedRowModel: getSortedRowModel(),
216
+ state: {
217
+ rowSelection: selectedChildrenState.reduce((acc, child, index) => {
218
+ acc[index] = true;
219
+ return acc;
220
+ }, {}),
221
+ },
222
+ onRowSelectionChange: (updater) => {
223
+ const newSelection =
224
+ typeof updater === "function" ? updater({}) : updater;
225
+ const selectedRows = Object.keys(newSelection).map(
226
+ (index) => children[parseInt(index)]
227
+ );
228
+ setSelectedChildrenState(selectedRows);
229
+ },
230
+ });
231
+
232
+ // Debounce search term
233
+ useEffect(() => {
234
+ const timer = setTimeout(() => {
235
+ setDebouncedSearchTerm(searchTerm);
236
+ setPagination((prev) => ({ ...prev, page: 1 }));
237
+ }, 300);
238
+
239
+ return () => clearTimeout(timer);
240
+ }, [searchTerm]);
241
+
242
+ // Search children
243
+ const searchChildren = useCallback(async () => {
244
+ if (!instance || !accounts[0]) {
245
+ setError("Authentication required");
246
+ return;
247
+ }
248
+
249
+ setLoading(true);
250
+ setError(null);
251
+
252
+ try {
253
+ const functionKey = import.meta.env.VITE_COMMON_API_FUNCTION_KEY || "";
254
+
255
+ const params = new URLSearchParams({
256
+ entra_id: accounts[0].localAccountId,
257
+ search_term: debouncedSearchTerm,
258
+ page: pagination.page,
259
+ page_size: pagination.pageSize,
260
+ application_context: applicationContext,
261
+ bypass_permissions: bypassPermissions.toString(),
262
+ });
263
+
264
+ // Handle site filtering
265
+ if (siteIds && siteIds.length > 0) {
266
+ params.append("site_ids", siteIds.join(","));
267
+ } else if (advancedFilters.selectedSiteId) {
268
+ params.append("site_id", advancedFilters.selectedSiteId.toString());
269
+ } else if (siteId) {
270
+ params.append("site_id", siteId.toString());
271
+ }
272
+
273
+ // Handle status filtering
274
+ if (advancedFilters.status && advancedFilters.status !== "all") {
275
+ params.append("status", advancedFilters.status);
276
+ } else if (activeOnly) {
277
+ params.append("active_only", "true");
278
+ }
279
+
280
+ // Add date of birth filters
281
+ if (advancedFilters.dobFrom) {
282
+ params.append("dob_from", advancedFilters.dobFrom);
283
+ }
284
+ if (advancedFilters.dobTo) {
285
+ params.append("dob_to", advancedFilters.dobTo);
286
+ }
287
+
288
+ // Add age filters
289
+ if (advancedFilters.ageFrom) {
290
+ params.append("age_from", advancedFilters.ageFrom);
291
+ }
292
+ if (advancedFilters.ageTo) {
293
+ params.append("age_to", advancedFilters.ageTo);
294
+ }
295
+
296
+ // Add sorting
297
+ params.append("sort_by", advancedFilters.sortBy);
298
+ params.append("sort_order", advancedFilters.sortOrder);
299
+
300
+ const apiResponse = await fetch(
301
+ `${
302
+ import.meta.env.VITE_COMMON_API_BASE_URL ||
303
+ "https://snaps-common-api.azurewebsites.net"
304
+ }/api/search-children?${params}`,
305
+ {
306
+ headers: {
307
+ "Content-Type": "application/json",
308
+ "x-functions-key": functionKey,
309
+ },
310
+ }
311
+ );
312
+
313
+ if (!apiResponse.ok) {
314
+ throw new Error(`API request failed: ${apiResponse.status}`);
315
+ }
316
+
317
+ const data = await apiResponse.json();
318
+
319
+ if (data.success) {
320
+ setChildren(data.data.children);
321
+ setPagination(data.data.pagination);
322
+ trackEvent("child_search_success", {
323
+ searchTerm: debouncedSearchTerm,
324
+ resultCount: data.data.children.length,
325
+ totalCount: data.data.pagination.totalCount,
326
+ });
327
+ } else {
328
+ throw new Error(data.error || "Search failed");
329
+ }
330
+ } catch (err) {
331
+ console.error("Error searching children:", err);
332
+ setError(err.message);
333
+ trackEvent("child_search_error", {
334
+ error: err.message,
335
+ searchTerm: debouncedSearchTerm,
336
+ });
337
+ } finally {
338
+ setLoading(false);
339
+ }
340
+ }, [
341
+ instance,
342
+ accounts,
343
+ debouncedSearchTerm,
344
+ pagination.page,
345
+ pagination.pageSize,
346
+ siteId,
347
+ siteIds,
348
+ activeOnly,
349
+ advancedFilters,
350
+ applicationContext,
351
+ bypassPermissions,
352
+ ]);
353
+
354
+ // Search when debounced term changes
355
+ useEffect(() => {
356
+ searchChildren();
357
+ }, [debouncedSearchTerm, pagination.page, searchChildren]);
358
+
359
+ // Initial search on component mount
360
+ useEffect(() => {
361
+ if (instance && accounts[0]) {
362
+ searchChildren();
363
+ }
364
+ }, [instance, accounts, searchChildren]);
365
+
366
+ const handlePageChange = (newPage) => {
367
+ setPagination((prev) => ({ ...prev, page: newPage }));
368
+ };
369
+
370
+ const handleChildSelect = (child) => {
371
+ if (onSelect) {
372
+ if (multiSelect) {
373
+ const isAlreadySelected = selectedChildrenState.some(
374
+ (selected) => selected.child_id === child.child_id
375
+ );
376
+
377
+ if (isAlreadySelected) {
378
+ const updatedSelection = selectedChildrenState.filter(
379
+ (selected) => selected.child_id !== child.child_id
380
+ );
381
+ setSelectedChildrenState(updatedSelection);
382
+ onSelect(updatedSelection);
383
+ } else {
384
+ if (maxSelections && selectedChildrenState.length >= maxSelections) {
385
+ return;
386
+ }
387
+ const updatedSelection = [...selectedChildrenState, child];
388
+ setSelectedChildrenState(updatedSelection);
389
+ onSelect(updatedSelection);
390
+ }
391
+ } else {
392
+ onSelect(child);
393
+ }
394
+ }
395
+ };
396
+
397
+ const clearFilters = () => {
398
+ setAdvancedFilters({
399
+ status: activeOnly ? "active" : "all",
400
+ selectedSiteId: "",
401
+ dobFrom: "",
402
+ dobTo: "",
403
+ ageFrom: "",
404
+ ageTo: "",
405
+ sortBy: "last_name",
406
+ sortOrder: "asc",
407
+ });
408
+ };
409
+
410
+ // Calculate pagination display values
411
+ const startItem = pagination.totalCount > 0 ? (pagination.page - 1) * pagination.pageSize + 1 : 0;
412
+ const endItem = Math.min(pagination.page * pagination.pageSize, pagination.totalCount);
413
+
414
+ return (
415
+ <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
416
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
417
+ {/* Header */}
418
+ <div className="mb-8">
419
+ <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
420
+ {title}
421
+ </h1>
422
+ {multiSelect && (
423
+ <div className="mt-2 flex items-center space-x-2">
424
+ <span className="px-3 py-1 text-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
425
+ {selectedChildrenState.length} selected
426
+ {maxSelections && ` / ${maxSelections}`}
427
+ </span>
428
+ </div>
429
+ )}
430
+ </div>
431
+
432
+ {/* Search and Filters */}
433
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
434
+ <div className="p-6">
435
+ {/* Search Input */}
436
+ <div className="relative mb-4">
437
+ <MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
438
+ <Input
439
+ type="text"
440
+ placeholder="Search by name or child ID..."
441
+ value={searchTerm}
442
+ onChange={(e) => setSearchTerm(e.target.value)}
443
+ className="pl-10"
444
+ />
445
+ </div>
446
+
447
+ {/* Advanced Filters Toggle */}
448
+ <div className="flex items-center justify-between">
449
+ <button
450
+ onClick={() => setIsAdvancedFiltersOpen(!isAdvancedFiltersOpen)}
451
+ className="flex items-center space-x-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
452
+ >
453
+ <FunnelIcon className="h-4 w-4" />
454
+ <span>Advanced Filters</span>
455
+ {isAdvancedFiltersOpen ? (
456
+ <ChevronUpIcon className="h-4 w-4" />
457
+ ) : (
458
+ <ChevronDownIcon className="h-4 w-4" />
459
+ )}
460
+ </button>
461
+ <button
462
+ onClick={clearFilters}
463
+ className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
464
+ >
465
+ Clear Filters
466
+ </button>
467
+ </div>
468
+
469
+ {/* Advanced Filters */}
470
+ {isAdvancedFiltersOpen && (
471
+ <div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
472
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
473
+ {/* Status Filter */}
474
+ <div>
475
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
476
+ Status
477
+ </label>
478
+ <Select
479
+ value={advancedFilters.status}
480
+ onChange={(e) =>
481
+ setAdvancedFilters((prev) => ({
482
+ ...prev,
483
+ status: e.target.value,
484
+ }))
485
+ }
486
+ >
487
+ <SelectOption value="all">All Children</SelectOption>
488
+ <SelectOption value="active">Active Only</SelectOption>
489
+ <SelectOption value="inactive">
490
+ Inactive Only
491
+ </SelectOption>
492
+ </Select>
493
+ </div>
494
+
495
+ {/* Site Filter */}
496
+ {sites && sites.length > 0 && (
497
+ <div>
498
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
499
+ Site
500
+ </label>
501
+ <Select
502
+ value={advancedFilters.selectedSiteId}
503
+ onChange={(e) =>
504
+ setAdvancedFilters((prev) => ({
505
+ ...prev,
506
+ selectedSiteId: e.target.value,
507
+ }))
508
+ }
509
+ >
510
+ <SelectOption value="">All Sites</SelectOption>
511
+ {sites.map((site) => (
512
+ <SelectOption key={site.site_id} value={site.site_id}>
513
+ {site.site_name}
514
+ </SelectOption>
515
+ ))}
516
+ </Select>
517
+ </div>
518
+ )}
519
+
520
+ {/* Date of Birth Range */}
521
+ <div>
522
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
523
+ Date of Birth From
524
+ </label>
525
+ <Input
526
+ type="date"
527
+ value={advancedFilters.dobFrom}
528
+ onChange={(e) =>
529
+ setAdvancedFilters((prev) => ({
530
+ ...prev,
531
+ dobFrom: e.target.value,
532
+ }))
533
+ }
534
+ />
535
+ </div>
536
+
537
+ <div>
538
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
539
+ Date of Birth To
540
+ </label>
541
+ <Input
542
+ type="date"
543
+ value={advancedFilters.dobTo}
544
+ onChange={(e) =>
545
+ setAdvancedFilters((prev) => ({
546
+ ...prev,
547
+ dobTo: e.target.value,
548
+ }))
549
+ }
550
+ />
551
+ </div>
552
+
553
+ {/* Age Range */}
554
+ <div>
555
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
556
+ Age From (months)
557
+ </label>
558
+ <Input
559
+ type="number"
560
+ min="0"
561
+ value={advancedFilters.ageFrom}
562
+ onChange={(e) =>
563
+ setAdvancedFilters((prev) => ({
564
+ ...prev,
565
+ ageFrom: e.target.value,
566
+ }))
567
+ }
568
+ placeholder="0"
569
+ />
570
+ </div>
571
+
572
+ <div>
573
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
574
+ Age To (months)
575
+ </label>
576
+ <Input
577
+ type="number"
578
+ min="0"
579
+ value={advancedFilters.ageTo}
580
+ onChange={(e) =>
581
+ setAdvancedFilters((prev) => ({
582
+ ...prev,
583
+ ageTo: e.target.value,
584
+ }))
585
+ }
586
+ placeholder="60"
587
+ />
588
+ </div>
589
+
590
+ {/* Sort Options */}
591
+ <div>
592
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
593
+ Sort By
594
+ </label>
595
+ <Select
596
+ value={advancedFilters.sortBy}
597
+ onChange={(e) =>
598
+ setAdvancedFilters((prev) => ({
599
+ ...prev,
600
+ sortBy: e.target.value,
601
+ }))
602
+ }
603
+ >
604
+ <SelectOption value="last_name">Last Name</SelectOption>
605
+ <SelectOption value="first_name">First Name</SelectOption>
606
+ <SelectOption value="full_name">Full Name</SelectOption>
607
+ <SelectOption value="date_of_birth">
608
+ Date of Birth
609
+ </SelectOption>
610
+ <SelectOption value="site_name">Site Name</SelectOption>
611
+ </Select>
612
+ </div>
613
+
614
+ <div>
615
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
616
+ Sort Order
617
+ </label>
618
+ <Select
619
+ value={advancedFilters.sortOrder}
620
+ onChange={(e) =>
621
+ setAdvancedFilters((prev) => ({
622
+ ...prev,
623
+ sortOrder: e.target.value,
624
+ }))
625
+ }
626
+ >
627
+ <SelectOption value="asc">Ascending</SelectOption>
628
+ <SelectOption value="desc">Descending</SelectOption>
629
+ </Select>
630
+ </div>
631
+ </div>
632
+ </div>
633
+ )}
634
+ </div>
635
+ </div>
636
+
637
+ {/* Results */}
638
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
639
+ {loading && (
640
+ <div className="flex items-center justify-center p-8">
641
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
642
+ </div>
643
+ )}
644
+
645
+ {error && (
646
+ <div className="p-6 text-center">
647
+ <div className="text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
648
+ {error}
649
+ </div>
650
+ </div>
651
+ )}
652
+
653
+ {!loading && !error && (
654
+ <>
655
+ {/* Results Header */}
656
+ <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
657
+ <div className="flex items-center justify-between">
658
+ <div className="text-sm text-gray-600 dark:text-gray-400">
659
+ {pagination.totalCount === 0
660
+ ? "No children found"
661
+ : `Showing ${startItem} to ${endItem} of ${pagination.totalCount} children`}
662
+ </div>
663
+ </div>
664
+ </div>
665
+
666
+ {/* Table */}
667
+ {children.length > 0 ? (
668
+ <div className="overflow-x-auto">
669
+ <Table>
670
+ <TableHeader>
671
+ {table.getHeaderGroups().map((headerGroup) => (
672
+ <TableRow key={headerGroup.id}>
673
+ {headerGroup.headers.map((header) => (
674
+ <TableHead key={header.id}>
675
+ {header.isPlaceholder
676
+ ? null
677
+ : flexRender(
678
+ header.column.columnDef.header,
679
+ header.getContext()
680
+ )}
681
+ </TableHead>
682
+ ))}
683
+ </TableRow>
684
+ ))}
685
+ </TableHeader>
686
+ <TableBody>
687
+ {table.getRowModel().rows?.length ? (
688
+ table.getRowModel().rows.map((row) => (
689
+ <TableRow
690
+ key={row.id}
691
+ data-state={row.getIsSelected() && "selected"}
692
+ onClick={() => handleChildSelect(row.original)}
693
+ className="cursor-pointer"
694
+ >
695
+ {row.getVisibleCells().map((cell) => (
696
+ <TableCell key={cell.id}>
697
+ {flexRender(
698
+ cell.column.columnDef.cell,
699
+ cell.getContext()
700
+ )}
701
+ </TableCell>
702
+ ))}
703
+ </TableRow>
704
+ ))
705
+ ) : (
706
+ <TableRow>
707
+ <TableCell
708
+ colSpan={columns.length}
709
+ className="h-24 text-center"
710
+ >
711
+ {debouncedSearchTerm
712
+ ? "No children found matching your search."
713
+ : "Start typing to search for children."}
714
+ </TableCell>
715
+ </TableRow>
716
+ )}
717
+ </TableBody>
718
+ </Table>
719
+ </div>
720
+ ) : (
721
+ <div className="p-6 text-center text-gray-500 dark:text-gray-400">
722
+ {debouncedSearchTerm
723
+ ? "No children found matching your search."
724
+ : "Start typing to search for children."}
725
+ </div>
726
+ )}
727
+
728
+ {/* Pagination */}
729
+ {pagination.totalCount > 0 && (
730
+ <div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
731
+ {/* Pagination Info */}
732
+ <div className="flex items-center justify-between mb-4">
733
+ <div className="text-sm text-gray-600 dark:text-gray-400">
734
+ Page {pagination.page} of {pagination.totalPages}
735
+ </div>
736
+ <div className="text-sm text-gray-600 dark:text-gray-400">
737
+ {pagination.totalCount} total children
738
+ </div>
739
+ </div>
740
+
741
+ {/* Pagination Controls */}
742
+ {pagination.totalPages > 1 && (
743
+ <Pagination>
744
+ <PaginationContent>
745
+ <PaginationItem>
746
+ <PaginationPrevious
747
+ onClick={() => handlePageChange(pagination.page - 1)}
748
+ className={cn(
749
+ !pagination.hasPreviousPage &&
750
+ "pointer-events-none opacity-50"
751
+ )}
752
+ />
753
+ </PaginationItem>
754
+
755
+ {/* Page numbers */}
756
+ {Array.from(
757
+ { length: pagination.totalPages },
758
+ (_, i) => i + 1
759
+ )
760
+ .filter((page) => {
761
+ const current = pagination.page;
762
+ const total = pagination.totalPages;
763
+ return (
764
+ page === 1 ||
765
+ page === total ||
766
+ (page >= current - 1 && page <= current + 1)
767
+ );
768
+ })
769
+ .map((page, index, array) => {
770
+ if (index > 0 && array[index - 1] !== page - 1) {
771
+ return (
772
+ <React.Fragment key={`ellipsis-${page}`}>
773
+ <PaginationItem>
774
+ <PaginationEllipsis />
775
+ </PaginationItem>
776
+ <PaginationItem>
777
+ <PaginationLink
778
+ onClick={() => handlePageChange(page)}
779
+ isActive={page === pagination.page}
780
+ >
781
+ {page}
782
+ </PaginationLink>
783
+ </PaginationItem>
784
+ </React.Fragment>
785
+ );
786
+ }
787
+ return (
788
+ <PaginationItem key={page}>
789
+ <PaginationLink
790
+ onClick={() => handlePageChange(page)}
791
+ isActive={page === pagination.page}
792
+ >
793
+ {page}
794
+ </PaginationLink>
795
+ </PaginationItem>
796
+ );
797
+ })}
798
+
799
+ <PaginationItem>
800
+ <PaginationNext
801
+ onClick={() => handlePageChange(pagination.page + 1)}
802
+ className={cn(
803
+ !pagination.hasNextPage &&
804
+ "pointer-events-none opacity-50"
805
+ )}
806
+ />
807
+ </PaginationItem>
808
+ </PaginationContent>
809
+ </Pagination>
810
+ )}
811
+ </div>
812
+ )}
813
+ </>
814
+ )}
815
+ </div>
816
+ </div>
817
+ </div>
818
+ );
819
+ };
820
+
821
+ export default ChildSearchPage;
@@ -0,0 +1,78 @@
1
+ // Demo component for ChildSearchPage
2
+ // Shows how to use the new full page child search component
3
+
4
+ import React, { useState } from "react";
5
+ import { ChildSearchPage } from "./index";
6
+
7
+ const ChildSearchPageDemo = () => {
8
+ const [selectedChild, setSelectedChild] = useState(null);
9
+ const [selectedChildren, setSelectedChildren] = useState([]);
10
+
11
+ // Mock sites data for demo
12
+ const mockSites = [
13
+ { site_id: 1, site_name: "Sunshine Nursery" },
14
+ { site_id: 2, site_name: "Rainbow Daycare" },
15
+ { site_id: 3, site_name: "Little Stars Preschool" },
16
+ ];
17
+
18
+ const handleSingleSelect = (child) => {
19
+ setSelectedChild(child);
20
+ console.log("Selected child:", child);
21
+ };
22
+
23
+ const handleMultiSelect = (children) => {
24
+ setSelectedChildren(children);
25
+ console.log("Selected children:", children);
26
+ };
27
+
28
+ return (
29
+ <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
30
+ {/* Demo Controls */}
31
+ <div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4">
32
+ <div className="max-w-7xl mx-auto">
33
+ <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
34
+ ChildSearchPage Demo
35
+ </h2>
36
+
37
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
38
+ <div>
39
+ <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
40
+ Single Select Mode
41
+ </h3>
42
+ {selectedChild && (
43
+ <div className="text-sm text-gray-600 dark:text-gray-400">
44
+ Selected: {selectedChild.full_name} (ID:{" "}
45
+ {selectedChild.child_id})
46
+ </div>
47
+ )}
48
+ </div>
49
+
50
+ <div>
51
+ <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
52
+ Multi Select Mode
53
+ </h3>
54
+ {selectedChildren.length > 0 && (
55
+ <div className="text-sm text-gray-600 dark:text-gray-400">
56
+ Selected: {selectedChildren.length} children
57
+ </div>
58
+ )}
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ {/* ChildSearchPage Component */}
65
+ <ChildSearchPage
66
+ title="Search Children - Demo"
67
+ sites={mockSites}
68
+ activeOnly={true}
69
+ applicationContext="demo"
70
+ onSelect={handleSingleSelect}
71
+ multiSelect={false}
72
+ showAdvancedFilters={true}
73
+ />
74
+ </div>
75
+ );
76
+ };
77
+
78
+ export default ChildSearchPageDemo;
@@ -0,0 +1,51 @@
1
+ // shadcn-style Button component with variants
2
+ // Provides consistent button styling across the application
3
+
4
+ import * as React from "react";
5
+ import { cva } from "class-variance-authority";
6
+ import { cn } from "../../lib/utils";
7
+
8
+ const buttonVariants = cva(
9
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
10
+ {
11
+ variants: {
12
+ variant: {
13
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
16
+ outline:
17
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
20
+ ghost: "hover:bg-accent hover:text-accent-foreground",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-10 px-4 py-2",
25
+ sm: "h-9 rounded-md px-3",
26
+ lg: "h-11 rounded-md px-8",
27
+ icon: "h-10 w-10",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: "default",
32
+ size: "default",
33
+ },
34
+ }
35
+ );
36
+
37
+ const Button = React.forwardRef(
38
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
39
+ const Comp = asChild ? "span" : "button";
40
+ return (
41
+ <Comp
42
+ className={cn(buttonVariants({ variant, size, className }), "cursor-pointer")}
43
+ ref={ref}
44
+ {...props}
45
+ />
46
+ );
47
+ }
48
+ );
49
+ Button.displayName = "Button";
50
+
51
+ export { Button, buttonVariants };
@@ -0,0 +1,22 @@
1
+ // shadcn-style Input component
2
+ // Provides consistent input styling across the application
3
+
4
+ import * as React from "react";
5
+ import { cn } from "../../lib/utils";
6
+
7
+ const Input = React.forwardRef(({ className, type, ...props }, ref) => {
8
+ return (
9
+ <input
10
+ type={type}
11
+ className={cn(
12
+ "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
13
+ className
14
+ )}
15
+ ref={ref}
16
+ {...props}
17
+ />
18
+ );
19
+ });
20
+ Input.displayName = "Input";
21
+
22
+ export { Input };
@@ -0,0 +1,95 @@
1
+ // shadcn-style Pagination component
2
+ // Provides consistent pagination styling for tables
3
+
4
+ import * as React from "react";
5
+ import { cn } from "../../lib/utils";
6
+ import { Button } from "./button";
7
+ import { ChevronLeftIcon, ChevronRightIcon, EllipsisHorizontalIcon } from "@heroicons/react/24/outline";
8
+
9
+ const Pagination = ({ className, ...props }) => (
10
+ <nav
11
+ role="navigation"
12
+ aria-label="pagination"
13
+ className={cn("mx-auto flex w-full justify-center", className)}
14
+ {...props}
15
+ />
16
+ );
17
+ Pagination.displayName = "Pagination";
18
+
19
+ const PaginationContent = React.forwardRef(({ className, ...props }, ref) => (
20
+ <ul
21
+ ref={ref}
22
+ className={cn("flex flex-row items-center gap-1", className)}
23
+ {...props}
24
+ />
25
+ ));
26
+ PaginationContent.displayName = "PaginationContent";
27
+
28
+ const PaginationItem = React.forwardRef(({ className, ...props }, ref) => (
29
+ <li ref={ref} className={cn("", className)} {...props} />
30
+ ));
31
+ PaginationItem.displayName = "PaginationItem";
32
+
33
+ const PaginationLink = React.forwardRef(
34
+ ({ className, isActive, size = "icon", ...props }, ref) => (
35
+ <Button
36
+ aria-current={isActive ? "page" : undefined}
37
+ variant={isActive ? "outline" : "ghost"}
38
+ size={size}
39
+ ref={ref}
40
+ className={cn("cursor-pointer", className)}
41
+ {...props}
42
+ />
43
+ )
44
+ );
45
+ PaginationLink.displayName = "PaginationLink";
46
+
47
+ const PaginationPrevious = React.forwardRef(({ className, ...props }, ref) => (
48
+ <PaginationLink
49
+ aria-label="Go to previous page"
50
+ size="default"
51
+ ref={ref}
52
+ className={cn("gap-1 pl-2.5", className)}
53
+ {...props}
54
+ >
55
+ <ChevronLeftIcon className="h-4 w-4" />
56
+ <span>Previous</span>
57
+ </PaginationLink>
58
+ ));
59
+ PaginationPrevious.displayName = "PaginationPrevious";
60
+
61
+ const PaginationNext = React.forwardRef(({ className, ...props }, ref) => (
62
+ <PaginationLink
63
+ aria-label="Go to next page"
64
+ size="default"
65
+ ref={ref}
66
+ className={cn("gap-1 pr-2.5", className)}
67
+ {...props}
68
+ >
69
+ <span>Next</span>
70
+ <ChevronRightIcon className="h-4 w-4" />
71
+ </PaginationLink>
72
+ ));
73
+ PaginationNext.displayName = "PaginationNext";
74
+
75
+ const PaginationEllipsis = ({ className, ...props }) => (
76
+ <span
77
+ aria-hidden
78
+ className={cn("flex h-9 w-9 items-center justify-center", className)}
79
+ {...props}
80
+ >
81
+ <EllipsisHorizontalIcon className="h-4 w-4" />
82
+ <span className="sr-only">More pages</span>
83
+ </span>
84
+ );
85
+ PaginationEllipsis.displayName = "PaginationEllipsis";
86
+
87
+ export {
88
+ Pagination,
89
+ PaginationContent,
90
+ PaginationEllipsis,
91
+ PaginationItem,
92
+ PaginationLink,
93
+ PaginationNext,
94
+ PaginationPrevious,
95
+ };
@@ -0,0 +1,34 @@
1
+ // shadcn-style Select component
2
+ // Provides consistent select styling across the application
3
+
4
+ import * as React from "react";
5
+ import { cn } from "../../lib/utils";
6
+
7
+ const Select = React.forwardRef(({ className, children, ...props }, ref) => {
8
+ return (
9
+ <select
10
+ className={cn(
11
+ "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
12
+ className
13
+ )}
14
+ ref={ref}
15
+ {...props}
16
+ >
17
+ {children}
18
+ </select>
19
+ );
20
+ });
21
+ Select.displayName = "Select";
22
+
23
+ const SelectOption = React.forwardRef(
24
+ ({ className, children, ...props }, ref) => {
25
+ return (
26
+ <option className={cn("", className)} ref={ref} {...props}>
27
+ {children}
28
+ </option>
29
+ );
30
+ }
31
+ );
32
+ SelectOption.displayName = "SelectOption";
33
+
34
+ export { Select, SelectOption };
@@ -0,0 +1,95 @@
1
+ // shadcn-style Table components
2
+ // Provides consistent table styling for TanStack table integration
3
+
4
+ import * as React from "react";
5
+ import { cn } from "../../lib/utils";
6
+
7
+ const Table = React.forwardRef(({ className, ...props }, ref) => (
8
+ <div className="relative w-full overflow-auto">
9
+ <table
10
+ ref={ref}
11
+ className={cn("w-full caption-bottom text-sm", className)}
12
+ {...props}
13
+ />
14
+ </div>
15
+ ));
16
+ Table.displayName = "Table";
17
+
18
+ const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
19
+ <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
20
+ ));
21
+ TableHeader.displayName = "TableHeader";
22
+
23
+ const TableBody = React.forwardRef(({ className, ...props }, ref) => (
24
+ <tbody
25
+ ref={ref}
26
+ className={cn("[&_tr:last-child]:border-0", className)}
27
+ {...props}
28
+ />
29
+ ));
30
+ TableBody.displayName = "TableBody";
31
+
32
+ const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
33
+ <tfoot
34
+ ref={ref}
35
+ className={cn(
36
+ "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
37
+ className
38
+ )}
39
+ {...props}
40
+ />
41
+ ));
42
+ TableFooter.displayName = "TableFooter";
43
+
44
+ const TableRow = React.forwardRef(({ className, ...props }, ref) => (
45
+ <tr
46
+ ref={ref}
47
+ className={cn(
48
+ "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
49
+ className
50
+ )}
51
+ {...props}
52
+ />
53
+ ));
54
+ TableRow.displayName = "TableRow";
55
+
56
+ const TableHead = React.forwardRef(({ className, ...props }, ref) => (
57
+ <th
58
+ ref={ref}
59
+ className={cn(
60
+ "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
61
+ className
62
+ )}
63
+ {...props}
64
+ />
65
+ ));
66
+ TableHead.displayName = "TableHead";
67
+
68
+ const TableCell = React.forwardRef(({ className, ...props }, ref) => (
69
+ <td
70
+ ref={ref}
71
+ className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
72
+ {...props}
73
+ />
74
+ ));
75
+ TableCell.displayName = "TableCell";
76
+
77
+ const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
78
+ <caption
79
+ ref={ref}
80
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
81
+ {...props}
82
+ />
83
+ ));
84
+ TableCaption.displayName = "TableCaption";
85
+
86
+ export {
87
+ Table,
88
+ TableHeader,
89
+ TableBody,
90
+ TableFooter,
91
+ TableHead,
92
+ TableRow,
93
+ TableCell,
94
+ TableCaption,
95
+ };
package/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { default as AuthButtons } from "./AuthButtons";
2
2
  export { default as ThemeToggle } from "./ThemeToggle";
3
3
  export { default as ChildSearchModal } from "./ChildSearchModal";
4
+ export { default as ChildSearchPage } from "./ChildSearchPage";
5
+ export { default as ChildSearchPageDemo } from "./ChildSearchPageDemo";
4
6
  export { default as ThemeToggleTest } from "./ThemeToggleTest";
5
7
  export { default as LandingPage } from "./LandingPage";
6
8
  export { configureTelemetry } from "./telemetry";
@@ -0,0 +1,9 @@
1
+ // Utility functions for shadcn styling
2
+ // This file provides the cn function for combining class names with proper Tailwind CSS merging
3
+
4
+ import { clsx } from "clsx";
5
+ import { twMerge } from "tailwind-merge";
6
+
7
+ export function cn(...inputs) {
8
+ return twMerge(clsx(inputs));
9
+ }