@snapdragonsnursery/react-components 1.20.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/package.json +1 -1
- package/src/ChildSearchModal.jsx +40 -7
- package/src/components/EmployeeSelect.jsx +49 -15
- package/src/index.d.ts +1 -0
package/README.md
CHANGED
|
@@ -42,6 +42,58 @@ function MyComponent() {
|
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
### ChildSearchModal with Room Filter
|
|
46
|
+
|
|
47
|
+
The component supports room filtering in two ways:
|
|
48
|
+
|
|
49
|
+
**Option 1: Rooms from `sites` prop** (rooms included on site objects)
|
|
50
|
+
```jsx
|
|
51
|
+
const sites = [
|
|
52
|
+
{
|
|
53
|
+
site_id: 1,
|
|
54
|
+
site_name: 'Main Office',
|
|
55
|
+
rooms: [
|
|
56
|
+
{ room_id: 10, room_name: 'Toddlers' },
|
|
57
|
+
{ room_id: 11, room_name: 'Pre-School' }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
<ChildSearchModal
|
|
63
|
+
isOpen={isModalOpen}
|
|
64
|
+
onClose={() => setIsModalOpen(false)}
|
|
65
|
+
onSelect={handleSelect}
|
|
66
|
+
sites={sites}
|
|
67
|
+
showAdvancedFilters={true}
|
|
68
|
+
/>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Option 2: Rooms from `availableRooms` prop** (recommended when rooms come from separate data)
|
|
72
|
+
```jsx
|
|
73
|
+
const availableRooms = [
|
|
74
|
+
{ room_id: 10, room_name: 'Toddlers', site_id: 1 },
|
|
75
|
+
{ room_id: 11, room_name: 'Pre-School', site_id: 1 },
|
|
76
|
+
{ room_id: 20, room_name: 'Infants', site_id: 2 }
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
<ChildSearchModal
|
|
80
|
+
isOpen={isModalOpen}
|
|
81
|
+
onClose={() => setIsModalOpen(false)}
|
|
82
|
+
onSelect={handleSelect}
|
|
83
|
+
sites={sites} // Just need site IDs/names for site filter
|
|
84
|
+
availableRooms={availableRooms}
|
|
85
|
+
showAdvancedFilters={true}
|
|
86
|
+
showRoomFilter={true}
|
|
87
|
+
/>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The room filter will automatically appear when:
|
|
91
|
+
- A site is selected in advanced filters
|
|
92
|
+
- Room data is available (from either `sites[].rooms` or `availableRooms`)
|
|
93
|
+
- `showRoomFilter` is `true` (default)
|
|
94
|
+
|
|
95
|
+
You can disable the room filter with `showRoomFilter={false}` if needed.
|
|
96
|
+
|
|
45
97
|
### EmployeeSelect Example
|
|
46
98
|
|
|
47
99
|
```jsx
|
package/package.json
CHANGED
package/src/ChildSearchModal.jsx
CHANGED
|
@@ -8,6 +8,15 @@ import React, {
|
|
|
8
8
|
import { useMsal } from "@azure/msal-react";
|
|
9
9
|
import { trackEvent } from "./telemetry";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* ChildSearchModal - Advanced child search and selection component
|
|
13
|
+
*
|
|
14
|
+
* Room data can be provided in two ways:
|
|
15
|
+
* 1. Via `sites` prop with `rooms` array on each site object: { site_id, site_name, rooms: [{ room_id, room_name }] }
|
|
16
|
+
* 2. Via `availableRooms` prop directly: [{ room_id, room_name, site_id }]
|
|
17
|
+
*
|
|
18
|
+
* The component prioritises `availableRooms` if provided, otherwise extracts from `sites[].rooms`.
|
|
19
|
+
*/
|
|
11
20
|
const ChildSearchModal = ({
|
|
12
21
|
isOpen,
|
|
13
22
|
onClose,
|
|
@@ -29,6 +38,8 @@ const ChildSearchModal = ({
|
|
|
29
38
|
className = "",
|
|
30
39
|
showAdvancedFilters = false, // Whether to show advanced filter options
|
|
31
40
|
showSiteFilter = true, // Whether to show site filter dropdown
|
|
41
|
+
showRoomFilter = true, // Whether to show room filter (only displays when rooms are available)
|
|
42
|
+
availableRooms = null, // Array of room objects { room_id, room_name, site_id } to populate the room filter
|
|
32
43
|
multiSelect = false, // Enable multiple child selection
|
|
33
44
|
maxSelections = null, // Maximum number of children that can be selected (null = unlimited)
|
|
34
45
|
selectedChildren, // Array of already selected children (for multi-select mode)
|
|
@@ -298,22 +309,44 @@ const ChildSearchModal = ({
|
|
|
298
309
|
// Fetch rooms for selected site
|
|
299
310
|
useEffect(() => {
|
|
300
311
|
const fetchRooms = async () => {
|
|
301
|
-
if (!advancedFilters.selectedSiteId)
|
|
312
|
+
if (!advancedFilters.selectedSiteId) {
|
|
313
|
+
setRooms([]);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
302
316
|
|
|
303
317
|
try {
|
|
304
|
-
|
|
318
|
+
const selectedSiteId = parseInt(advancedFilters.selectedSiteId, 10);
|
|
319
|
+
|
|
320
|
+
// Priority 1: Use availableRooms prop if provided (filtered by selected site, active, and childcare)
|
|
321
|
+
if (availableRooms && availableRooms.length > 0) {
|
|
322
|
+
const filteredRooms = availableRooms.filter(
|
|
323
|
+
(room) =>
|
|
324
|
+
(!room.site_id || room.site_id === selectedSiteId) &&
|
|
325
|
+
room.is_active === true &&
|
|
326
|
+
room.childcare === true
|
|
327
|
+
);
|
|
328
|
+
setRooms(filteredRooms);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Priority 2: Extract rooms from sites prop (filtered by active and childcare)
|
|
305
333
|
if (sites && sites.length > 0) {
|
|
306
334
|
const selectedSite = sites.find(
|
|
307
|
-
(site) => site.site_id ===
|
|
335
|
+
(site) => site.site_id === selectedSiteId
|
|
308
336
|
);
|
|
309
337
|
|
|
310
338
|
if (selectedSite && selectedSite.rooms) {
|
|
311
|
-
|
|
339
|
+
const filteredRooms = selectedSite.rooms.filter(
|
|
340
|
+
(room) =>
|
|
341
|
+
room.is_active === true &&
|
|
342
|
+
room.childcare === true
|
|
343
|
+
);
|
|
344
|
+
setRooms(filteredRooms);
|
|
312
345
|
return;
|
|
313
346
|
}
|
|
314
347
|
}
|
|
315
348
|
|
|
316
|
-
//
|
|
349
|
+
// No rooms available
|
|
317
350
|
setRooms([]);
|
|
318
351
|
} catch (err) {
|
|
319
352
|
console.error("Error fetching rooms:", err);
|
|
@@ -322,7 +355,7 @@ const ChildSearchModal = ({
|
|
|
322
355
|
};
|
|
323
356
|
|
|
324
357
|
fetchRooms();
|
|
325
|
-
}, [advancedFilters.selectedSiteId, sites]);
|
|
358
|
+
}, [advancedFilters.selectedSiteId, sites, availableRooms]);
|
|
326
359
|
|
|
327
360
|
// Reset selection when modal opens (for multi-select mode)
|
|
328
361
|
useEffect(() => {
|
|
@@ -670,7 +703,7 @@ const ChildSearchModal = ({
|
|
|
670
703
|
)}
|
|
671
704
|
|
|
672
705
|
{/* Room Filter */}
|
|
673
|
-
{advancedFilters.selectedSiteId && rooms && rooms.length > 0 && (
|
|
706
|
+
{showRoomFilter && advancedFilters.selectedSiteId && rooms && rooms.length > 0 && (
|
|
674
707
|
<div>
|
|
675
708
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
676
709
|
Room
|
|
@@ -44,6 +44,7 @@ import React from "react";
|
|
|
44
44
|
import { Users, Search, X } from "lucide-react";
|
|
45
45
|
import { cn } from "../lib/utils";
|
|
46
46
|
|
|
47
|
+
|
|
47
48
|
export const EmployeeSelect = ({
|
|
48
49
|
value,
|
|
49
50
|
onChange,
|
|
@@ -116,6 +117,32 @@ export const EmployeeSelect = ({
|
|
|
116
117
|
return `hsl(${hue}, 70%, 50%)`;
|
|
117
118
|
};
|
|
118
119
|
|
|
120
|
+
// Helper component for employee item avatar with error handling
|
|
121
|
+
// Accepts blob URLs (from Graph API) or direct image URLs
|
|
122
|
+
const EmployeeAvatar = ({ avatarUrl, fullName, size = "h-8 w-8" }) => {
|
|
123
|
+
const [imageError, setImageError] = React.useState(false);
|
|
124
|
+
|
|
125
|
+
if (imageError || !avatarUrl) {
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
className={`flex ${size} items-center justify-center rounded-full text-xs font-medium text-white flex-shrink-0`}
|
|
129
|
+
style={{ backgroundColor: getAvatarColor(fullName) }}
|
|
130
|
+
>
|
|
131
|
+
{getInitials(fullName)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<img
|
|
138
|
+
src={avatarUrl}
|
|
139
|
+
alt={fullName}
|
|
140
|
+
className={`${size} rounded-full object-cover flex-shrink-0`}
|
|
141
|
+
onError={() => setImageError(true)}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
119
146
|
const processedItems = React.useMemo(() => {
|
|
120
147
|
const sourceList = enableServerSearch ? searchResults : items;
|
|
121
148
|
const list = Array.isArray(sourceList) ? [...sourceList] : [];
|
|
@@ -135,6 +162,8 @@ export const EmployeeSelect = ({
|
|
|
135
162
|
roleName: String(e.role_name || ""),
|
|
136
163
|
employeeId: String(e.employee_id || ""),
|
|
137
164
|
email: String(e.email || ""),
|
|
165
|
+
// Use provided photo URLs (blob URLs from Graph API fetched in parent component)
|
|
166
|
+
avatarUrl: e.avatar_url || e.photo_url || e.graph_photo || e.avatar || null,
|
|
138
167
|
}))
|
|
139
168
|
.filter((e) => e.entraId);
|
|
140
169
|
|
|
@@ -173,8 +202,15 @@ export const EmployeeSelect = ({
|
|
|
173
202
|
return searchFiltered;
|
|
174
203
|
}, [items, filter, groupBy, searchTerm, enableServerSearch, searchResults, requireSearch, value]);
|
|
175
204
|
|
|
176
|
-
const
|
|
205
|
+
const selectedEmployeeRaw = processedItems.find((e) => e.entraId === value) ||
|
|
177
206
|
(enableServerSearch ? searchResults.find((e) => String(e.entra_id) === value) : items.find((e) => String(e.entra_id) === value));
|
|
207
|
+
|
|
208
|
+
const selectedEmployee = selectedEmployeeRaw ? {
|
|
209
|
+
entraId: String(selectedEmployeeRaw.entra_id || selectedEmployeeRaw.entraId || ""),
|
|
210
|
+
fullName: String(selectedEmployeeRaw.full_name || selectedEmployeeRaw.fullName || "Unknown"),
|
|
211
|
+
siteName: String(selectedEmployeeRaw.site_name || selectedEmployeeRaw.siteName || ""),
|
|
212
|
+
avatarUrl: selectedEmployeeRaw.avatar_url || selectedEmployeeRaw.photo_url || selectedEmployeeRaw.graph_photo || selectedEmployeeRaw.avatar || selectedEmployeeRaw.avatarUrl || null,
|
|
213
|
+
} : null;
|
|
178
214
|
|
|
179
215
|
const handleSelect = (employeeId) => {
|
|
180
216
|
onChange?.(employeeId);
|
|
@@ -190,12 +226,11 @@ export const EmployeeSelect = ({
|
|
|
190
226
|
const renderEmployeeItem = (employee) => (
|
|
191
227
|
<div className="flex items-center gap-2 p-2 hover:bg-accent rounded-md cursor-pointer">
|
|
192
228
|
{showAvatar && (
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
</div>
|
|
229
|
+
<EmployeeAvatar
|
|
230
|
+
avatarUrl={employee.avatarUrl}
|
|
231
|
+
fullName={employee.fullName}
|
|
232
|
+
size="h-8 w-8"
|
|
233
|
+
/>
|
|
199
234
|
)}
|
|
200
235
|
<div className="flex-1 min-w-0">
|
|
201
236
|
<div className="text-sm font-medium truncate">{employee.fullName}</div>
|
|
@@ -214,15 +249,14 @@ export const EmployeeSelect = ({
|
|
|
214
249
|
<div className={cn("relative", className)} disabled={disabled}>
|
|
215
250
|
{/* Selected value display */}
|
|
216
251
|
{selectedEmployee && (
|
|
217
|
-
|
|
252
|
+
<div className="mb-2 flex items-center justify-between bg-secondary px-3 py-2 rounded-md">
|
|
218
253
|
<div className="flex items-center gap-2 min-w-0">
|
|
219
254
|
{showAvatar && (
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
</div>
|
|
255
|
+
<EmployeeAvatar
|
|
256
|
+
avatarUrl={selectedEmployee.avatarUrl}
|
|
257
|
+
fullName={selectedEmployee.fullName}
|
|
258
|
+
size="h-6 w-6"
|
|
259
|
+
/>
|
|
226
260
|
)}
|
|
227
261
|
<div className="min-w-0">
|
|
228
262
|
<div className="text-sm font-medium truncate">{selectedEmployee.fullName}</div>
|
|
@@ -300,7 +334,7 @@ export const EmployeeSelect = ({
|
|
|
300
334
|
className="absolute top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-md z-50 overflow-hidden"
|
|
301
335
|
style={{ maxHeight }}
|
|
302
336
|
>
|
|
303
|
-
<div className="overflow-y-auto">
|
|
337
|
+
<div className="overflow-y-auto" style={{ maxHeight }}>
|
|
304
338
|
{requireSearch && !searchTerm && processedItems.length === 0 ? (
|
|
305
339
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
306
340
|
Start typing to search for employees...
|
package/src/index.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export const EmployeeSearchPage: React.ComponentType<any>
|
|
|
34
34
|
export const EmployeeSearchModal: React.ComponentType<any>
|
|
35
35
|
export const EmployeeSearchDemo: React.ComponentType<any>
|
|
36
36
|
export const EmployeeSearchFilters: React.ComponentType<any>
|
|
37
|
+
export const EmployeeSelect: React.ComponentType<any>
|
|
37
38
|
|
|
38
39
|
export const DateRangePicker: React.ComponentType<any>
|
|
39
40
|
export interface DatePickerProps {
|