@snapdragonsnursery/react-components 1.17.9 → 1.18.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/package.json +1 -1
- package/src/components/EmployeeSelect.jsx +210 -162
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// EmployeeSelect Component
|
|
2
2
|
// A lightweight, searchable employee selector built with shadcn/ui primitives.
|
|
3
|
+
// Redesigned as a combobox with search as the primary input.
|
|
3
4
|
// Use this for simple employee selection with pre-loaded data. For advanced search
|
|
4
5
|
// with server-side filtering, pagination, and complex filters, use EmployeeSearchModal instead.
|
|
5
6
|
//
|
|
@@ -26,30 +27,23 @@
|
|
|
26
27
|
// - showSiteName?: boolean // show site below name (default: true)
|
|
27
28
|
// - showEmployeeId?: boolean // show employee ID below name (default: false)
|
|
28
29
|
// - showEmail?: boolean // show email below name (default: false)
|
|
29
|
-
// - placeholder?: string //
|
|
30
|
-
// - searchPlaceholder?: string //
|
|
30
|
+
// - placeholder?: string // search input placeholder (default: 'Search employees...')
|
|
31
|
+
// - searchPlaceholder?: string // alias for placeholder prop
|
|
31
32
|
// - disabled?: boolean
|
|
32
33
|
// - className?: string
|
|
33
34
|
// - allowAll?: boolean // include "All employees" option
|
|
34
35
|
// - allLabel?: string // custom label for all option
|
|
35
|
-
// - maxHeight?: string // max height for dropdown (default: '
|
|
36
|
+
// - maxHeight?: string // max height for dropdown (default: '300px')
|
|
36
37
|
// - enableServerSearch?: boolean // enable server-side search (default: false)
|
|
37
38
|
// - onSearchChange?: (query: string) => void // callback for search query changes
|
|
38
39
|
// - searchResults?: Array<employee> // search results from server
|
|
39
40
|
// - isSearching?: boolean // loading state for server search
|
|
41
|
+
// - requireSearch?: boolean // hide results until user types (default: false)
|
|
40
42
|
|
|
41
43
|
import React from "react";
|
|
42
|
-
import { Users, Search } from "lucide-react";
|
|
44
|
+
import { Users, Search, X } from "lucide-react";
|
|
43
45
|
import { cn } from "../lib/utils";
|
|
44
46
|
|
|
45
|
-
import {
|
|
46
|
-
Select,
|
|
47
|
-
SelectContent,
|
|
48
|
-
SelectItem,
|
|
49
|
-
SelectTrigger,
|
|
50
|
-
SelectValue,
|
|
51
|
-
} from "./ui/radix-select";
|
|
52
|
-
|
|
53
47
|
export const EmployeeSelect = ({
|
|
54
48
|
value,
|
|
55
49
|
onChange,
|
|
@@ -60,42 +54,42 @@ export const EmployeeSelect = ({
|
|
|
60
54
|
showSiteName = true,
|
|
61
55
|
showEmployeeId = false,
|
|
62
56
|
showEmail = false,
|
|
63
|
-
placeholder = "
|
|
64
|
-
searchPlaceholder
|
|
57
|
+
placeholder = "Search employees...",
|
|
58
|
+
searchPlaceholder,
|
|
65
59
|
disabled = false,
|
|
66
60
|
className,
|
|
67
61
|
allowAll = false,
|
|
68
62
|
allLabel = "All employees",
|
|
69
|
-
maxHeight = "
|
|
70
|
-
// New props for server-side search
|
|
63
|
+
maxHeight = "300px",
|
|
71
64
|
enableServerSearch = false,
|
|
72
65
|
onSearchChange = null,
|
|
73
66
|
searchResults = [],
|
|
74
67
|
isSearching = false,
|
|
68
|
+
requireSearch = false,
|
|
75
69
|
}) => {
|
|
76
70
|
const [searchTerm, setSearchTerm] = React.useState("");
|
|
77
71
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
78
|
-
const
|
|
79
|
-
|
|
72
|
+
const inputRef = React.useRef(null);
|
|
73
|
+
|
|
74
|
+
// Use searchPlaceholder if provided, otherwise use placeholder
|
|
75
|
+
const effectivePlaceholder = searchPlaceholder || placeholder;
|
|
80
76
|
|
|
81
77
|
// Debounce search term for server-side search
|
|
82
78
|
React.useEffect(() => {
|
|
83
79
|
const timer = setTimeout(() => {
|
|
84
|
-
setDebouncedSearchTerm(searchTerm);
|
|
85
80
|
if (enableServerSearch && onSearchChange) {
|
|
86
81
|
onSearchChange(searchTerm);
|
|
87
82
|
}
|
|
88
|
-
}, 300);
|
|
83
|
+
}, 300);
|
|
89
84
|
|
|
90
85
|
return () => clearTimeout(timer);
|
|
91
86
|
}, [searchTerm, enableServerSearch, onSearchChange]);
|
|
92
87
|
|
|
93
88
|
// Maintain focus on search input when server results update
|
|
94
89
|
React.useEffect(() => {
|
|
95
|
-
if (enableServerSearch && isOpen &&
|
|
96
|
-
// Only refocus if we previously had focus (i.e., user was typing)
|
|
90
|
+
if (enableServerSearch && isOpen && inputRef.current && document.activeElement !== inputRef.current) {
|
|
97
91
|
if (searchTerm && !isSearching) {
|
|
98
|
-
|
|
92
|
+
inputRef.current.focus();
|
|
99
93
|
}
|
|
100
94
|
}
|
|
101
95
|
}, [searchResults, enableServerSearch, isOpen, searchTerm, isSearching]);
|
|
@@ -114,7 +108,6 @@ export const EmployeeSelect = ({
|
|
|
114
108
|
// Helper function to get avatar color based on name
|
|
115
109
|
const getAvatarColor = (name) => {
|
|
116
110
|
if (!name) return "#6b7280";
|
|
117
|
-
// Generate a consistent color based on the name
|
|
118
111
|
let hash = 0;
|
|
119
112
|
for (let i = 0; i < name.length; i++) {
|
|
120
113
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
@@ -124,19 +117,16 @@ export const EmployeeSelect = ({
|
|
|
124
117
|
};
|
|
125
118
|
|
|
126
119
|
const processedItems = React.useMemo(() => {
|
|
127
|
-
// Use server search results if enabled, otherwise use items
|
|
128
120
|
const sourceList = enableServerSearch ? searchResults : items;
|
|
129
121
|
const list = Array.isArray(sourceList) ? [...sourceList] : [];
|
|
130
122
|
|
|
131
|
-
// Filter by employee status
|
|
132
123
|
const filtered = list.filter((e) => {
|
|
133
124
|
if (filter === "active") {
|
|
134
125
|
return e.employee_status === "Active";
|
|
135
126
|
}
|
|
136
|
-
return true;
|
|
127
|
+
return true;
|
|
137
128
|
});
|
|
138
129
|
|
|
139
|
-
// Map to consistent structure
|
|
140
130
|
const mapped = filtered
|
|
141
131
|
.map((e) => ({
|
|
142
132
|
entraId: String(e.entra_id || ""),
|
|
@@ -146,11 +136,10 @@ export const EmployeeSelect = ({
|
|
|
146
136
|
employeeId: String(e.employee_id || ""),
|
|
147
137
|
email: String(e.email || ""),
|
|
148
138
|
}))
|
|
149
|
-
.filter((e) => e.entraId);
|
|
139
|
+
.filter((e) => e.entraId);
|
|
150
140
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
? mapped // Server search already filtered
|
|
141
|
+
const searchFiltered = enableServerSearch
|
|
142
|
+
? mapped
|
|
154
143
|
: mapped.filter((e) => {
|
|
155
144
|
if (!searchTerm) return true;
|
|
156
145
|
const searchLower = searchTerm.toLowerCase();
|
|
@@ -163,7 +152,6 @@ export const EmployeeSelect = ({
|
|
|
163
152
|
);
|
|
164
153
|
});
|
|
165
154
|
|
|
166
|
-
// Sort based on grouping
|
|
167
155
|
searchFiltered.sort((a, b) => {
|
|
168
156
|
if (groupBy === "site") {
|
|
169
157
|
const siteCompare = a.siteName.localeCompare(b.siteName);
|
|
@@ -178,38 +166,44 @@ export const EmployeeSelect = ({
|
|
|
178
166
|
return a.fullName.localeCompare(b.fullName);
|
|
179
167
|
});
|
|
180
168
|
|
|
169
|
+
if (requireSearch && !searchTerm) {
|
|
170
|
+
return searchFiltered.filter((e) => e.entraId === value);
|
|
171
|
+
}
|
|
172
|
+
|
|
181
173
|
return searchFiltered;
|
|
182
|
-
}, [items, filter, groupBy, searchTerm, enableServerSearch, searchResults]);
|
|
174
|
+
}, [items, filter, groupBy, searchTerm, enableServerSearch, searchResults, requireSearch, value]);
|
|
183
175
|
|
|
184
|
-
const selectedEmployee = processedItems.find((e) => e.entraId === value)
|
|
176
|
+
const selectedEmployee = processedItems.find((e) => e.entraId === value) ||
|
|
177
|
+
(enableServerSearch ? searchResults.find((e) => String(e.entra_id) === value) : items.find((e) => String(e.entra_id) === value));
|
|
185
178
|
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
179
|
+
const handleSelect = (employeeId) => {
|
|
180
|
+
onChange?.(employeeId);
|
|
181
|
+
setSearchTerm("");
|
|
182
|
+
setIsOpen(false);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handleClear = () => {
|
|
186
|
+
onChange?.(undefined);
|
|
187
|
+
setSearchTerm("");
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const renderEmployeeItem = (employee) => (
|
|
191
|
+
<div className="flex items-center gap-2 p-2 hover:bg-accent rounded-md cursor-pointer">
|
|
192
|
+
{showAvatar && (
|
|
189
193
|
<div
|
|
190
|
-
className="flex h-
|
|
194
|
+
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium text-white flex-shrink-0"
|
|
191
195
|
style={{ backgroundColor: getAvatarColor(employee.fullName) }}
|
|
192
196
|
>
|
|
193
197
|
{getInitials(employee.fullName)}
|
|
194
198
|
</div>
|
|
195
199
|
)}
|
|
196
200
|
<div className="flex-1 min-w-0">
|
|
197
|
-
<div className="
|
|
198
|
-
|
|
199
|
-
</div>
|
|
200
|
-
{showSiteName && employee.siteName && (
|
|
201
|
+
<div className="text-sm font-medium truncate">{employee.fullName}</div>
|
|
202
|
+
{(showSiteName || showEmail) && (
|
|
201
203
|
<div className="text-xs text-muted-foreground truncate">
|
|
202
|
-
{employee.siteName}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
{showEmployeeId && employee.employeeId && (
|
|
206
|
-
<div className="text-xs text-muted-foreground truncate">
|
|
207
|
-
ID: {employee.employeeId}
|
|
208
|
-
</div>
|
|
209
|
-
)}
|
|
210
|
-
{showEmail && employee.email && (
|
|
211
|
-
<div className="text-xs text-muted-foreground truncate">
|
|
212
|
-
{employee.email}
|
|
204
|
+
{showSiteName && employee.siteName && <span>{employee.siteName}</span>}
|
|
205
|
+
{showSiteName && showEmail && employee.siteName && employee.email && <span> • </span>}
|
|
206
|
+
{showEmail && employee.email && <span>{employee.email}</span>}
|
|
213
207
|
</div>
|
|
214
208
|
)}
|
|
215
209
|
</div>
|
|
@@ -217,120 +211,174 @@ export const EmployeeSelect = ({
|
|
|
217
211
|
);
|
|
218
212
|
|
|
219
213
|
return (
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
disabled={disabled}
|
|
232
|
-
open={isOpen}
|
|
233
|
-
onOpenChange={setIsOpen}
|
|
234
|
-
>
|
|
235
|
-
<SelectTrigger className={cn("w-full", className)}>
|
|
236
|
-
<div className="flex items-center gap-2">
|
|
237
|
-
{selectedEmployee && showAvatar ? (
|
|
238
|
-
<div
|
|
239
|
-
className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium text-white"
|
|
240
|
-
style={{ backgroundColor: getAvatarColor(selectedEmployee.fullName) }}
|
|
241
|
-
>
|
|
242
|
-
{getInitials(selectedEmployee.fullName)}
|
|
243
|
-
</div>
|
|
244
|
-
) : (
|
|
245
|
-
<Users className="h-4 w-4" />
|
|
246
|
-
)}
|
|
247
|
-
<SelectValue placeholder={placeholder} />
|
|
248
|
-
</div>
|
|
249
|
-
</SelectTrigger>
|
|
250
|
-
<SelectContent
|
|
251
|
-
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"
|
|
252
|
-
style={{ "--select-max-height": maxHeight }}
|
|
253
|
-
>
|
|
254
|
-
{/* Search input */}
|
|
255
|
-
<div className="p-2 border-b">
|
|
256
|
-
<div className="relative">
|
|
257
|
-
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
258
|
-
<input
|
|
259
|
-
ref={searchInputRef}
|
|
260
|
-
type="text"
|
|
261
|
-
placeholder={searchPlaceholder}
|
|
262
|
-
value={searchTerm}
|
|
263
|
-
onChange={(e) => setSearchTerm(e.target.value)}
|
|
264
|
-
className="w-full pl-8 pr-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 bg-background"
|
|
265
|
-
onClick={(e) => e.stopPropagation()}
|
|
266
|
-
/>
|
|
267
|
-
{enableServerSearch && isSearching && (
|
|
268
|
-
<div className="absolute right-2 top-1/2 transform -translate-y-1/2">
|
|
269
|
-
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
|
214
|
+
<div className={cn("relative", className)} disabled={disabled}>
|
|
215
|
+
{/* Selected value display */}
|
|
216
|
+
{selectedEmployee && (
|
|
217
|
+
<div className="mb-2 flex items-center justify-between bg-secondary px-3 py-2 rounded-md">
|
|
218
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
219
|
+
{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)}
|
|
270
225
|
</div>
|
|
271
226
|
)}
|
|
227
|
+
<div className="min-w-0">
|
|
228
|
+
<div className="text-sm font-medium truncate">{selectedEmployee.fullName}</div>
|
|
229
|
+
{showSiteName && selectedEmployee.siteName && (
|
|
230
|
+
<div className="text-xs text-muted-foreground truncate">{selectedEmployee.siteName}</div>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
272
233
|
</div>
|
|
234
|
+
<button
|
|
235
|
+
onClick={handleClear}
|
|
236
|
+
className="ml-2 p-1 hover:bg-background rounded flex-shrink-0"
|
|
237
|
+
aria-label="Clear selection"
|
|
238
|
+
>
|
|
239
|
+
<X className="h-4 w-4" />
|
|
240
|
+
</button>
|
|
273
241
|
</div>
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{/* Search input */}
|
|
245
|
+
<div className="relative">
|
|
246
|
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
|
247
|
+
<input
|
|
248
|
+
ref={inputRef}
|
|
249
|
+
type="text"
|
|
250
|
+
placeholder={effectivePlaceholder}
|
|
251
|
+
value={searchTerm}
|
|
252
|
+
onChange={(e) => {
|
|
253
|
+
setSearchTerm(e.target.value);
|
|
254
|
+
setIsOpen(true);
|
|
255
|
+
}}
|
|
256
|
+
onFocus={() => setIsOpen(true)}
|
|
257
|
+
disabled={disabled}
|
|
258
|
+
className={cn(
|
|
259
|
+
"w-full pl-10 pr-10 py-2 text-sm border rounded-md",
|
|
260
|
+
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
261
|
+
"bg-background transition-colors",
|
|
262
|
+
"placeholder:text-muted-foreground",
|
|
263
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
264
|
+
)}
|
|
265
|
+
/>
|
|
266
|
+
{enableServerSearch && isSearching && (
|
|
267
|
+
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
268
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
{searchTerm && !isSearching && (
|
|
272
|
+
<button
|
|
273
|
+
onClick={() => {
|
|
274
|
+
setSearchTerm("");
|
|
275
|
+
inputRef.current?.focus();
|
|
276
|
+
}}
|
|
277
|
+
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
278
|
+
aria-label="Clear search"
|
|
279
|
+
>
|
|
280
|
+
<X className="h-4 w-4" />
|
|
281
|
+
</button>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{/* Results dropdown */}
|
|
286
|
+
{isOpen && (
|
|
287
|
+
<>
|
|
288
|
+
{/* Backdrop to close dropdown */}
|
|
289
|
+
<div
|
|
290
|
+
className="fixed inset-0 z-40"
|
|
291
|
+
onClick={() => setIsOpen(false)}
|
|
292
|
+
onKeyDown={(e) => {
|
|
293
|
+
if (e.key === "Escape") setIsOpen(false);
|
|
294
|
+
}}
|
|
295
|
+
/>
|
|
296
|
+
|
|
297
|
+
{/* Results popover */}
|
|
298
|
+
<div
|
|
299
|
+
className="absolute top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-md z-50 overflow-hidden"
|
|
300
|
+
style={{ maxHeight }}
|
|
301
|
+
>
|
|
302
|
+
<div className="overflow-y-auto">
|
|
303
|
+
{requireSearch && !searchTerm && processedItems.length === 0 ? (
|
|
304
|
+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
305
|
+
Start typing to search for employees...
|
|
288
306
|
</div>
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
<SelectItem key={employee.entraId} value={employee.entraId}>
|
|
293
|
-
{renderEmployeeItem(employee, isSelected)}
|
|
294
|
-
</SelectItem>
|
|
295
|
-
);
|
|
296
|
-
})}
|
|
297
|
-
</div>
|
|
298
|
-
))
|
|
299
|
-
: groupBy === "role"
|
|
300
|
-
? // Group by role
|
|
301
|
-
Object.entries(
|
|
302
|
-
processedItems.reduce((groups, employee) => {
|
|
303
|
-
const role = employee.roleName || "Other";
|
|
304
|
-
if (!groups[role]) groups[role] = [];
|
|
305
|
-
groups[role].push(employee);
|
|
306
|
-
return groups;
|
|
307
|
-
}, {})
|
|
308
|
-
).map(([roleName, employees]) => (
|
|
309
|
-
<div key={roleName}>
|
|
310
|
-
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
311
|
-
{roleName}
|
|
312
|
-
</div>
|
|
313
|
-
{employees.map((employee) => {
|
|
314
|
-
const isSelected = value === employee.entraId;
|
|
315
|
-
return (
|
|
316
|
-
<SelectItem key={employee.entraId} value={employee.entraId}>
|
|
317
|
-
{renderEmployeeItem(employee, isSelected)}
|
|
318
|
-
</SelectItem>
|
|
319
|
-
);
|
|
320
|
-
})}
|
|
307
|
+
) : processedItems.length === 0 ? (
|
|
308
|
+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
309
|
+
No employees found
|
|
321
310
|
</div>
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
311
|
+
) : (
|
|
312
|
+
<>
|
|
313
|
+
{allowAll && (
|
|
314
|
+
<div
|
|
315
|
+
onClick={() => handleSelect(undefined)}
|
|
316
|
+
className="px-3 py-2 hover:bg-accent cursor-pointer border-b text-sm"
|
|
317
|
+
>
|
|
318
|
+
{allLabel}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
{groupBy === "site"
|
|
322
|
+
? Object.entries(
|
|
323
|
+
processedItems.reduce((groups, employee) => {
|
|
324
|
+
const site = employee.siteName || "Other";
|
|
325
|
+
if (!groups[site]) groups[site] = [];
|
|
326
|
+
groups[site].push(employee);
|
|
327
|
+
return groups;
|
|
328
|
+
}, {})
|
|
329
|
+
).map(([siteName, employees]) => (
|
|
330
|
+
<div key={siteName}>
|
|
331
|
+
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted">
|
|
332
|
+
{siteName}
|
|
333
|
+
</div>
|
|
334
|
+
{employees.map((employee) => (
|
|
335
|
+
<div
|
|
336
|
+
key={employee.entraId}
|
|
337
|
+
onClick={() => handleSelect(employee.entraId)}
|
|
338
|
+
>
|
|
339
|
+
{renderEmployeeItem(employee)}
|
|
340
|
+
</div>
|
|
341
|
+
))}
|
|
342
|
+
</div>
|
|
343
|
+
))
|
|
344
|
+
: groupBy === "role"
|
|
345
|
+
? Object.entries(
|
|
346
|
+
processedItems.reduce((groups, employee) => {
|
|
347
|
+
const role = employee.roleName || "Other";
|
|
348
|
+
if (!groups[role]) groups[role] = [];
|
|
349
|
+
groups[role].push(employee);
|
|
350
|
+
return groups;
|
|
351
|
+
}, {})
|
|
352
|
+
).map(([roleName, employees]) => (
|
|
353
|
+
<div key={roleName}>
|
|
354
|
+
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted">
|
|
355
|
+
{roleName}
|
|
356
|
+
</div>
|
|
357
|
+
{employees.map((employee) => (
|
|
358
|
+
<div
|
|
359
|
+
key={employee.entraId}
|
|
360
|
+
onClick={() => handleSelect(employee.entraId)}
|
|
361
|
+
>
|
|
362
|
+
{renderEmployeeItem(employee)}
|
|
363
|
+
</div>
|
|
364
|
+
))}
|
|
365
|
+
</div>
|
|
366
|
+
))
|
|
367
|
+
: processedItems.map((employee) => (
|
|
368
|
+
<div
|
|
369
|
+
key={employee.entraId}
|
|
370
|
+
onClick={() => handleSelect(employee.entraId)}
|
|
371
|
+
>
|
|
372
|
+
{renderEmployeeItem(employee)}
|
|
373
|
+
</div>
|
|
374
|
+
))}
|
|
375
|
+
</>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
334
382
|
);
|
|
335
383
|
};
|
|
336
384
|
|