@snapdragonsnursery/react-components 1.16.1 → 1.17.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
@@ -6,6 +6,8 @@ A collection of reusable React components for Snapdragons Nursery applications.
6
6
 
7
7
  - **ChildSearchModal**: Advanced child search and selection component with filtering, pagination, and multi-select capabilities
8
8
  - **ChildSearchFilters**: Advanced filtering component with date range picker, status, site, and age filters (includes Apply button for better UX)
9
+ - **EmployeeSelect**: Lightweight, searchable employee selector with grouping and optional avatars
10
+ - **EmployeeSearchModal**: Advanced employee search with server-side filtering, pagination, and complex filters
9
11
  - **DateRangePicker**: Shadcn-style date range picker component (supports optional presets)
10
12
  - **DatePicker**: Shadcn-style single date picker component
11
13
  - **Calendar**: Official shadcn calendar component
@@ -40,6 +42,44 @@ function MyComponent() {
40
42
  }
41
43
  ```
42
44
 
45
+ ### EmployeeSelect Example
46
+
47
+ ```jsx
48
+ import { EmployeeSelect } from '@snapdragonsnursery/react-components';
49
+
50
+ function MyComponent() {
51
+ const [selectedEntraId, setSelectedEntraId] = useState();
52
+ const employees = [
53
+ {
54
+ entra_id: '123-456',
55
+ full_name: 'Jane Smith',
56
+ site_name: 'Main Office',
57
+ role_name: 'Teacher',
58
+ employee_id: 'EMP001',
59
+ email: 'jane@example.com',
60
+ employee_status: 'Active'
61
+ },
62
+ // ... more employees
63
+ ];
64
+
65
+ return (
66
+ <EmployeeSelect
67
+ value={selectedEntraId}
68
+ onChange={setSelectedEntraId}
69
+ items={employees}
70
+ groupBy="site"
71
+ showAvatar
72
+ showSiteName
73
+ placeholder="Select an employee..."
74
+ />
75
+ );
76
+ }
77
+ ```
78
+
79
+ **EmployeeSelect vs EmployeeSearchModal**:
80
+ - **EmployeeSelect**: Use for simple dropdown selection with pre-loaded employee data. Supports grouping by site/role, optional avatars, and built-in client-side search. Perfect for forms with limited employee lists.
81
+ - **EmployeeSearchModal**: Use for advanced search scenarios requiring server-side filtering, pagination, complex multi-criteria filters, and multi-select. Ideal for large employee databases.
82
+
43
83
  ### SoftWarningAlert Example
44
84
 
45
85
  ```jsx
@@ -198,6 +238,7 @@ VITE_APIM_SCOPE=api://your-apim-app-id/.default
198
238
 
199
239
  - [ChildSearchModal Documentation](docs/CHILD_SEARCH_MODAL_DOCUMENTATION.md)
200
240
  - [ChildSearchModal README](docs/CHILD_SEARCH_README.md)
241
+ - [EmployeeSelect Documentation](docs/EMPLOYEE_SELECT_DOCUMENTATION.md)
201
242
  - [Release Guide](./RELEASE.md)
202
243
  - [SoftWarningAlert](./SOFT_WARNING_ALERT.md)
203
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapdragonsnursery/react-components",
3
- "version": "1.16.1",
3
+ "version": "1.17.0",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -0,0 +1,259 @@
1
+ // EmployeeSelect Component
2
+ // A lightweight, searchable employee selector built with shadcn/ui primitives.
3
+ // Use this for simple employee selection with pre-loaded data. For advanced search
4
+ // with server-side filtering, pagination, and complex filters, use EmployeeSearchModal instead.
5
+ //
6
+ // Example:
7
+ // import { EmployeeSelect } from '@snapdragonsnursery/react-components'
8
+ // const [entraId, setEntraId] = useState()
9
+ // const { employees } = useAuth()
10
+ // <EmployeeSelect
11
+ // value={entraId}
12
+ // onChange={setEntraId}
13
+ // items={employees}
14
+ // groupBy="site"
15
+ // showAvatar
16
+ // showSiteName
17
+ // />
18
+ //
19
+ // Props:
20
+ // - value?: string // selected entra_id
21
+ // - onChange?: (entraId?: string) => void
22
+ // - items: Array<employee> // required employee list
23
+ // - filter?: 'active' | 'all' // filter by status (default: 'active')
24
+ // - groupBy?: 'site' | 'role' | 'none' // grouping (default: 'none')
25
+ // - showAvatar?: boolean // show colored initials (default: false)
26
+ // - showSiteName?: boolean // show site below name (default: true)
27
+ // - showEmployeeId?: boolean // show employee ID below name (default: false)
28
+ // - showEmail?: boolean // show email below name (default: false)
29
+ // - placeholder?: string
30
+ // - disabled?: boolean
31
+ // - className?: string
32
+ // - allowAll?: boolean // include "All employees" option
33
+ // - allLabel?: string // custom label for all option
34
+ // - maxHeight?: string // max height for dropdown (default: '160px')
35
+
36
+ import React from "react";
37
+ import { Users } from "lucide-react";
38
+ import { cn } from "../lib/utils";
39
+
40
+ import {
41
+ Select,
42
+ SelectContent,
43
+ SelectItem,
44
+ SelectTrigger,
45
+ SelectValue,
46
+ } from "./ui/select";
47
+
48
+ export const EmployeeSelect = ({
49
+ value,
50
+ onChange,
51
+ items = [],
52
+ filter = "active",
53
+ groupBy = "none",
54
+ showAvatar = false,
55
+ showSiteName = true,
56
+ showEmployeeId = false,
57
+ showEmail = false,
58
+ placeholder = "Select employee…",
59
+ disabled = false,
60
+ className,
61
+ allowAll = false,
62
+ allLabel = "All employees",
63
+ maxHeight = "160px",
64
+ }) => {
65
+ // Helper function to generate initials from full name
66
+ const getInitials = (name) => {
67
+ if (!name) return "??";
68
+ return name
69
+ .split(" ")
70
+ .map((word) => word.charAt(0))
71
+ .join("")
72
+ .toUpperCase()
73
+ .slice(0, 2);
74
+ };
75
+
76
+ // Helper function to get avatar color based on name
77
+ const getAvatarColor = (name) => {
78
+ if (!name) return "#6b7280";
79
+ // Generate a consistent color based on the name
80
+ let hash = 0;
81
+ for (let i = 0; i < name.length; i++) {
82
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
83
+ }
84
+ const hue = hash % 360;
85
+ return `hsl(${hue}, 70%, 50%)`;
86
+ };
87
+
88
+ const processedItems = React.useMemo(() => {
89
+ const list = Array.isArray(items) ? [...items] : [];
90
+
91
+ // Filter by employee status
92
+ const filtered = list.filter((e) => {
93
+ if (filter === "active") {
94
+ return e.employee_status === "Active";
95
+ }
96
+ return true; // 'all' - no filtering
97
+ });
98
+
99
+ // Map to consistent structure
100
+ const mapped = filtered
101
+ .map((e) => ({
102
+ entraId: String(e.entra_id || ""),
103
+ fullName: String(e.full_name || "Unknown"),
104
+ siteName: String(e.site_name || ""),
105
+ roleName: String(e.role_name || ""),
106
+ employeeId: String(e.employee_id || ""),
107
+ email: String(e.email || ""),
108
+ }))
109
+ .filter((e) => e.entraId); // Remove items without entra_id
110
+
111
+ // Sort based on grouping
112
+ mapped.sort((a, b) => {
113
+ if (groupBy === "site") {
114
+ const siteCompare = a.siteName.localeCompare(b.siteName);
115
+ if (siteCompare !== 0) return siteCompare;
116
+ return a.fullName.localeCompare(b.fullName);
117
+ }
118
+ if (groupBy === "role") {
119
+ const roleCompare = a.roleName.localeCompare(b.roleName);
120
+ if (roleCompare !== 0) return roleCompare;
121
+ return a.fullName.localeCompare(b.fullName);
122
+ }
123
+ return a.fullName.localeCompare(b.fullName);
124
+ });
125
+
126
+ return mapped;
127
+ }, [items, filter, groupBy]);
128
+
129
+ const selectedEmployee = processedItems.find((e) => e.entraId === value);
130
+
131
+ const renderEmployeeItem = (employee, isSelected = false) => (
132
+ <div className="flex items-center gap-2">
133
+ {showAvatar && !isSelected && (
134
+ <div
135
+ className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium text-white"
136
+ style={{ backgroundColor: getAvatarColor(employee.fullName) }}
137
+ >
138
+ {getInitials(employee.fullName)}
139
+ </div>
140
+ )}
141
+ <div className="flex-1 min-w-0">
142
+ <div className="flex items-center gap-2">
143
+ <span className="truncate">{employee.fullName}</span>
144
+ </div>
145
+ {showSiteName && employee.siteName && (
146
+ <div className="text-xs text-muted-foreground truncate">
147
+ {employee.siteName}
148
+ </div>
149
+ )}
150
+ {showEmployeeId && employee.employeeId && (
151
+ <div className="text-xs text-muted-foreground truncate">
152
+ ID: {employee.employeeId}
153
+ </div>
154
+ )}
155
+ {showEmail && employee.email && (
156
+ <div className="text-xs text-muted-foreground truncate">
157
+ {employee.email}
158
+ </div>
159
+ )}
160
+ </div>
161
+ </div>
162
+ );
163
+
164
+ return (
165
+ <Select
166
+ value={value || (allowAll ? "__all__" : undefined)}
167
+ onValueChange={(val) => {
168
+ if (val === "__all__") {
169
+ onChange?.(undefined);
170
+ } else {
171
+ onChange?.(val);
172
+ }
173
+ }}
174
+ disabled={disabled}
175
+ >
176
+ <SelectTrigger className={cn("w-full", className)}>
177
+ <div className="flex items-center gap-2">
178
+ {selectedEmployee && showAvatar ? (
179
+ <div
180
+ className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium text-white"
181
+ style={{ backgroundColor: getAvatarColor(selectedEmployee.fullName) }}
182
+ >
183
+ {getInitials(selectedEmployee.fullName)}
184
+ </div>
185
+ ) : (
186
+ <Users className="h-4 w-4" />
187
+ )}
188
+ <SelectValue placeholder={placeholder} />
189
+ </div>
190
+ </SelectTrigger>
191
+ <SelectContent
192
+ className="[&_[data-radix-select-viewport]]:max-h-[var(--select-max-height)] [&_[data-radix-select-viewport]]:overflow-y-auto [&>button[data-radix-select-scroll-up-button]]:hidden [&>button[data-radix-select-scroll-down-button]]:hidden"
193
+ style={{ "--select-max-height": maxHeight }}
194
+ >
195
+ {allowAll && <SelectItem value="__all__">{allLabel}</SelectItem>}
196
+ {groupBy === "site"
197
+ ? // Group by site
198
+ Object.entries(
199
+ processedItems.reduce((groups, employee) => {
200
+ const site = employee.siteName || "Other";
201
+ if (!groups[site]) groups[site] = [];
202
+ groups[site].push(employee);
203
+ return groups;
204
+ }, {})
205
+ ).map(([siteName, employees]) => (
206
+ <div key={siteName}>
207
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
208
+ {siteName}
209
+ </div>
210
+ {employees.map((employee) => {
211
+ const isSelected = value === employee.entraId;
212
+ return (
213
+ <SelectItem key={employee.entraId} value={employee.entraId}>
214
+ {renderEmployeeItem(employee, isSelected)}
215
+ </SelectItem>
216
+ );
217
+ })}
218
+ </div>
219
+ ))
220
+ : groupBy === "role"
221
+ ? // Group by role
222
+ Object.entries(
223
+ processedItems.reduce((groups, employee) => {
224
+ const role = employee.roleName || "Other";
225
+ if (!groups[role]) groups[role] = [];
226
+ groups[role].push(employee);
227
+ return groups;
228
+ }, {})
229
+ ).map(([roleName, employees]) => (
230
+ <div key={roleName}>
231
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
232
+ {roleName}
233
+ </div>
234
+ {employees.map((employee) => {
235
+ const isSelected = value === employee.entraId;
236
+ return (
237
+ <SelectItem key={employee.entraId} value={employee.entraId}>
238
+ {renderEmployeeItem(employee, isSelected)}
239
+ </SelectItem>
240
+ );
241
+ })}
242
+ </div>
243
+ ))
244
+ : // No grouping
245
+ processedItems.map((employee) => {
246
+ const isSelected = value === employee.entraId;
247
+ return (
248
+ <SelectItem key={employee.entraId} value={employee.entraId}>
249
+ {renderEmployeeItem(employee, isSelected)}
250
+ </SelectItem>
251
+ );
252
+ })}
253
+ </SelectContent>
254
+ </Select>
255
+ );
256
+ };
257
+
258
+ export default EmployeeSelect;
259
+
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ export { default as EmployeeSearchPage } from "./EmployeeSearchPage.jsx";
16
16
  export { default as EmployeeSearchModal } from "./EmployeeSearchModal.jsx";
17
17
  export { default as EmployeeSearchDemo } from "./EmployeeSearchDemo.jsx";
18
18
  export { default as EmployeeSearchFilters } from "./components/EmployeeSearchFilters.jsx";
19
+ export { default as EmployeeSelect } from "./components/EmployeeSelect.jsx";
19
20
 
20
21
  export { configureTelemetry } from "./telemetry.js";
21
22