@snapdragonsnursery/react-components 1.1.9 → 1.1.10

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