@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapdragonsnursery/react-components",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -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) return;
312
+ if (!advancedFilters.selectedSiteId) {
313
+ setRooms([]);
314
+ return;
315
+ }
302
316
 
303
317
  try {
304
- // Find the selected site and extract its rooms
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 === parseInt(advancedFilters.selectedSiteId, 10)
335
+ (site) => site.site_id === selectedSiteId
308
336
  );
309
337
 
310
338
  if (selectedSite && selectedSite.rooms) {
311
- setRooms(selectedSite.rooms);
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
- // If not available from sites, clear rooms
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 selectedEmployee = processedItems.find((e) => e.entraId === value) ||
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
- <div
194
- className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium text-white flex-shrink-0"
195
- style={{ backgroundColor: getAvatarColor(employee.fullName) }}
196
- >
197
- {getInitials(employee.fullName)}
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
- <div className="mb-2 flex items-center justify-between bg-secondary px-3 py-2 rounded-md">
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
- <div
221
- className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium text-white flex-shrink-0"
222
- style={{ backgroundColor: getAvatarColor(selectedEmployee.fullName) }}
223
- >
224
- {getInitials(selectedEmployee.fullName)}
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 {