@snapdragonsnursery/react-components 1.13.0 → 1.16.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 +4 -4
- package/package.json +1 -1
- package/src/ChildSearchModal.jsx +41 -11
- package/src/ChildSearchPage.jsx +33 -6
- package/src/EmployeeSearchDemo.jsx +37 -1
- package/src/EmployeeSearchModal.jsx +425 -122
- package/src/EmployeeSearchPage.jsx +193 -138
- package/src/__mocks__/importMetaEnv.js +2 -3
- package/src/components/ui/date-range-picker.jsx +68 -76
- package/src/components/ui/popover.jsx +1 -1
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { trackEvent } from "./telemetry";
|
|
15
15
|
import { Button } from "./components/ui/button";
|
|
16
16
|
import { Input } from "./components/ui/input";
|
|
17
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./components/ui/avatar";
|
|
17
18
|
import {
|
|
18
19
|
Table,
|
|
19
20
|
TableBody,
|
|
@@ -74,6 +75,21 @@ const EmployeeSearchModal = ({
|
|
|
74
75
|
showEndDateFilter = true,
|
|
75
76
|
showDbsFilter = true,
|
|
76
77
|
showWorkingHoursFilter = true,
|
|
78
|
+
// Profile pictures
|
|
79
|
+
showProfilePics = false,
|
|
80
|
+
// Layout controls
|
|
81
|
+
zIndex = 60,
|
|
82
|
+
maxHeightVh = 85,
|
|
83
|
+
// Auth options: provide token directly or a function to fetch it
|
|
84
|
+
authToken = null,
|
|
85
|
+
getAccessToken = null,
|
|
86
|
+
// Graph auth options specifically for Microsoft Graph calls (e.g., profile photos)
|
|
87
|
+
graphAuthToken = null,
|
|
88
|
+
getGraphAccessToken = null,
|
|
89
|
+
// Column customization
|
|
90
|
+
visibleColumns = null,
|
|
91
|
+
columnLabels = {},
|
|
92
|
+
columnRenderers = {},
|
|
77
93
|
}) => {
|
|
78
94
|
const [searchTerm, setSearchTerm] = useState("");
|
|
79
95
|
const [employees, setEmployees] = useState([]);
|
|
@@ -108,18 +124,122 @@ const EmployeeSearchModal = ({
|
|
|
108
124
|
|
|
109
125
|
// Table sorting state
|
|
110
126
|
const [sorting, setSorting] = useState([]);
|
|
111
|
-
const [debouncedAdvancedFilters, setDebouncedAdvancedFilters] =
|
|
127
|
+
const [debouncedAdvancedFilters, setDebouncedAdvancedFilters] =
|
|
128
|
+
useState(advancedFilters);
|
|
112
129
|
|
|
113
130
|
// State for multi-select mode
|
|
114
131
|
const [selectedEmployeesState, setSelectedEmployeesState] = useState(
|
|
115
132
|
selectedEmployees || []
|
|
116
133
|
);
|
|
117
134
|
|
|
135
|
+
// State for profile pictures
|
|
136
|
+
const [profilePics, setProfilePics] = useState(new Map());
|
|
137
|
+
const [loadingProfilePics, setLoadingProfilePics] = useState(new Set());
|
|
138
|
+
|
|
118
139
|
const { instance, accounts } = useMsal();
|
|
119
140
|
|
|
120
141
|
// Column helper for TanStack table
|
|
121
142
|
const columnHelper = createColumnHelper();
|
|
122
143
|
|
|
144
|
+
// Function to fetch profile picture from Microsoft Graph
|
|
145
|
+
const fetchProfilePicture = useCallback(
|
|
146
|
+
async (entraId) => {
|
|
147
|
+
if (
|
|
148
|
+
!entraId ||
|
|
149
|
+
profilePics.has(entraId) ||
|
|
150
|
+
loadingProfilePics.has(entraId)
|
|
151
|
+
) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setLoadingProfilePics((prev) => new Set(prev).add(entraId));
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Get Graph API token (prefer explicit Graph token accessors to avoid APIM token misuse)
|
|
159
|
+
let graphToken = null;
|
|
160
|
+
|
|
161
|
+
if (typeof getGraphAccessToken === "function") {
|
|
162
|
+
graphToken = await getGraphAccessToken();
|
|
163
|
+
} else if (graphAuthToken) {
|
|
164
|
+
graphToken = graphAuthToken;
|
|
165
|
+
} else if (instance && accounts[0]) {
|
|
166
|
+
// Fallback: acquire Graph token via MSAL
|
|
167
|
+
// Prefer broader scopes that allow reading other users' profiles/photos when consented
|
|
168
|
+
const graphScopes = ["User.Read", "Directory.Read.All"]; // host app should consent these
|
|
169
|
+
try {
|
|
170
|
+
const response = await instance.acquireTokenSilent({
|
|
171
|
+
account: accounts[0],
|
|
172
|
+
scopes: graphScopes,
|
|
173
|
+
});
|
|
174
|
+
graphToken = response.accessToken;
|
|
175
|
+
} catch (silentErr) {
|
|
176
|
+
try {
|
|
177
|
+
const response = await instance.acquireTokenPopup({
|
|
178
|
+
scopes: graphScopes,
|
|
179
|
+
});
|
|
180
|
+
graphToken = response.accessToken;
|
|
181
|
+
} catch (popupErr) {
|
|
182
|
+
console.warn(
|
|
183
|
+
"Failed to get Graph token for profile picture:",
|
|
184
|
+
popupErr
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!graphToken) {
|
|
192
|
+
console.warn("No Graph token available for profile picture");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Try different Graph photo endpoints (small size first)
|
|
197
|
+
const tryUrls = [
|
|
198
|
+
`https://graph.microsoft.com/v1.0/users/${entraId}/photos/48x48/$value`,
|
|
199
|
+
`https://graph.microsoft.com/v1.0/users/${entraId}/photo/$value`,
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
for (const url of tryUrls) {
|
|
203
|
+
const response = await fetch(url, {
|
|
204
|
+
headers: {
|
|
205
|
+
Authorization: `Bearer ${graphToken}`,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (response.ok) {
|
|
210
|
+
const blob = await response.blob();
|
|
211
|
+
const imageUrl = URL.createObjectURL(blob);
|
|
212
|
+
setProfilePics((prev) => new Map(prev).set(entraId, imageUrl));
|
|
213
|
+
return; // success
|
|
214
|
+
}
|
|
215
|
+
if (response.status === 404) {
|
|
216
|
+
// No profile picture available - store null to avoid retrying
|
|
217
|
+
setProfilePics((prev) => new Map(prev).set(entraId, null));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.warn(`Failed to fetch profile picture for ${entraId}: Non-200 response`);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.warn(`Error fetching profile picture for ${entraId}:`, error);
|
|
225
|
+
} finally {
|
|
226
|
+
setLoadingProfilePics((prev) => {
|
|
227
|
+
const newSet = new Set(prev);
|
|
228
|
+
newSet.delete(entraId);
|
|
229
|
+
return newSet;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
[
|
|
234
|
+
instance,
|
|
235
|
+
accounts,
|
|
236
|
+
getGraphAccessToken,
|
|
237
|
+
graphAuthToken,
|
|
238
|
+
profilePics,
|
|
239
|
+
loadingProfilePics,
|
|
240
|
+
]
|
|
241
|
+
);
|
|
242
|
+
|
|
123
243
|
// Helper function to create sortable header
|
|
124
244
|
const createSortableHeader = (column, label) => {
|
|
125
245
|
return ({ column: tableColumn }) => {
|
|
@@ -149,94 +269,144 @@ const EmployeeSearchModal = ({
|
|
|
149
269
|
};
|
|
150
270
|
};
|
|
151
271
|
|
|
152
|
-
//
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
272
|
+
// Profile picture column
|
|
273
|
+
const profilePicColumn = columnHelper.display({
|
|
274
|
+
id: "profile_pic",
|
|
275
|
+
header: columnLabels.profile_pic || "Photo",
|
|
276
|
+
cell: ({ row }) => {
|
|
277
|
+
const entraId = row.original.entra_id;
|
|
278
|
+
const profilePic = profilePics.get(entraId);
|
|
279
|
+
const isLoading = loadingProfilePics.has(entraId);
|
|
280
|
+
|
|
281
|
+
// Generate initials for fallback
|
|
282
|
+
const initials =
|
|
283
|
+
row.original.full_name
|
|
284
|
+
?.split(" ")
|
|
285
|
+
.map((name) => name.charAt(0))
|
|
286
|
+
.join("")
|
|
287
|
+
.toUpperCase()
|
|
288
|
+
.slice(0, 2) || "??";
|
|
289
|
+
|
|
290
|
+
if (isLoading) {
|
|
291
|
+
return (
|
|
292
|
+
<Avatar className="w-8 h-8">
|
|
293
|
+
<AvatarFallback className="bg-gray-200 dark:bg-gray-700">
|
|
294
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
|
295
|
+
</AvatarFallback>
|
|
296
|
+
</Avatar>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<Avatar className="w-8 h-8">
|
|
302
|
+
{profilePic && (
|
|
303
|
+
<AvatarImage
|
|
304
|
+
src={profilePic}
|
|
305
|
+
alt={`${row.original.full_name} profile`}
|
|
306
|
+
/>
|
|
307
|
+
)}
|
|
308
|
+
<AvatarFallback className="bg-blue-500 text-white text-sm font-medium">
|
|
309
|
+
{initials}
|
|
310
|
+
</AvatarFallback>
|
|
311
|
+
</Avatar>
|
|
312
|
+
);
|
|
313
|
+
},
|
|
314
|
+
size: 60,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Define default data columns (excluding optional select)
|
|
318
|
+
const defaultDataColumns = [
|
|
180
319
|
columnHelper.accessor("full_name", {
|
|
181
|
-
header: createSortableHeader(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
<div className="font-medium">{row.original.full_name}</div>
|
|
185
|
-
<div className="text-sm text-gray-500">
|
|
186
|
-
ID: {row.original.employee_id}
|
|
187
|
-
</div>
|
|
188
|
-
</div>
|
|
320
|
+
header: createSortableHeader(
|
|
321
|
+
"full_name",
|
|
322
|
+
columnLabels.full_name || "Name"
|
|
189
323
|
),
|
|
324
|
+
cell: ({ row }) =>
|
|
325
|
+
columnRenderers.full_name ? (
|
|
326
|
+
columnRenderers.full_name(row.original)
|
|
327
|
+
) : (
|
|
328
|
+
<div>
|
|
329
|
+
<div className="font-medium">{row.original.full_name}</div>
|
|
330
|
+
<div className="text-sm text-gray-500">
|
|
331
|
+
ID: {row.original.employee_id}
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
),
|
|
190
335
|
}),
|
|
191
|
-
// Site column - sortable
|
|
192
336
|
columnHelper.accessor("site_name", {
|
|
193
|
-
header: createSortableHeader(
|
|
194
|
-
|
|
195
|
-
|
|
337
|
+
header: createSortableHeader(
|
|
338
|
+
"site_name",
|
|
339
|
+
columnLabels.site_name || "Site"
|
|
196
340
|
),
|
|
341
|
+
cell: ({ row }) =>
|
|
342
|
+
columnRenderers.site_name ? (
|
|
343
|
+
columnRenderers.site_name(row.original)
|
|
344
|
+
) : (
|
|
345
|
+
<span>{row.original.site_name}</span>
|
|
346
|
+
),
|
|
197
347
|
}),
|
|
198
|
-
// Role column - sortable
|
|
199
348
|
columnHelper.accessor("role_name", {
|
|
200
|
-
header: createSortableHeader(
|
|
201
|
-
|
|
202
|
-
|
|
349
|
+
header: createSortableHeader(
|
|
350
|
+
"role_name",
|
|
351
|
+
columnLabels.role_name || "Role"
|
|
203
352
|
),
|
|
353
|
+
cell: ({ row }) =>
|
|
354
|
+
columnRenderers.role_name ? (
|
|
355
|
+
columnRenderers.role_name(row.original)
|
|
356
|
+
) : (
|
|
357
|
+
<span>{row.original.role_name}</span>
|
|
358
|
+
),
|
|
204
359
|
}),
|
|
205
|
-
// Email column
|
|
206
360
|
columnHelper.accessor("email", {
|
|
207
|
-
header: "Email",
|
|
208
|
-
cell: ({ row }) =>
|
|
209
|
-
|
|
210
|
-
|
|
361
|
+
header: columnLabels.email || "Email",
|
|
362
|
+
cell: ({ row }) =>
|
|
363
|
+
columnRenderers.email ? (
|
|
364
|
+
columnRenderers.email(row.original)
|
|
365
|
+
) : (
|
|
366
|
+
<span className="text-sm">{row.original.email}</span>
|
|
367
|
+
),
|
|
211
368
|
}),
|
|
212
|
-
// Start Date column - sortable
|
|
213
369
|
columnHelper.accessor("start_date", {
|
|
214
|
-
header: createSortableHeader(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
{row.original.start_date
|
|
218
|
-
? new Date(row.original.start_date).toLocaleDateString("en-GB")
|
|
219
|
-
: "N/A"}
|
|
220
|
-
</span>
|
|
370
|
+
header: createSortableHeader(
|
|
371
|
+
"start_date",
|
|
372
|
+
columnLabels.start_date || "Start Date"
|
|
221
373
|
),
|
|
374
|
+
cell: ({ row }) =>
|
|
375
|
+
columnRenderers.start_date ? (
|
|
376
|
+
columnRenderers.start_date(row.original)
|
|
377
|
+
) : (
|
|
378
|
+
<span>
|
|
379
|
+
{row.original.start_date
|
|
380
|
+
? new Date(row.original.start_date).toLocaleDateString("en-GB")
|
|
381
|
+
: "N/A"}
|
|
382
|
+
</span>
|
|
383
|
+
),
|
|
222
384
|
}),
|
|
223
|
-
// Status column - sortable
|
|
224
385
|
columnHelper.accessor("employee_status", {
|
|
225
|
-
header: createSortableHeader(
|
|
386
|
+
header: createSortableHeader(
|
|
387
|
+
"employee_status",
|
|
388
|
+
columnLabels.employee_status || "Status"
|
|
389
|
+
),
|
|
226
390
|
cell: ({ row }) => {
|
|
391
|
+
if (columnRenderers.employee_status)
|
|
392
|
+
return columnRenderers.employee_status(row.original);
|
|
227
393
|
const status = row.original.employee_status;
|
|
228
394
|
const statusColors = {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
395
|
+
Active:
|
|
396
|
+
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
397
|
+
Inactive:
|
|
398
|
+
"bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
|
|
399
|
+
"On Leave":
|
|
400
|
+
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
|
401
|
+
Terminated:
|
|
402
|
+
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
|
233
403
|
};
|
|
234
|
-
|
|
235
404
|
return (
|
|
236
405
|
<span
|
|
237
406
|
className={cn(
|
|
238
407
|
"px-2 py-1 text-xs rounded-full",
|
|
239
|
-
statusColors[status] ||
|
|
408
|
+
statusColors[status] ||
|
|
409
|
+
"bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200"
|
|
240
410
|
)}
|
|
241
411
|
>
|
|
242
412
|
{status || "Unknown"}
|
|
@@ -244,19 +414,59 @@ const EmployeeSearchModal = ({
|
|
|
244
414
|
);
|
|
245
415
|
},
|
|
246
416
|
}),
|
|
247
|
-
// Working Hours column
|
|
248
417
|
columnHelper.accessor("total_hours_per_week", {
|
|
249
|
-
header: "Hours/Week",
|
|
250
|
-
cell: ({ row }) =>
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
418
|
+
header: columnLabels.total_hours_per_week || "Hours/Week",
|
|
419
|
+
cell: ({ row }) =>
|
|
420
|
+
columnRenderers.total_hours_per_week ? (
|
|
421
|
+
columnRenderers.total_hours_per_week(row.original)
|
|
422
|
+
) : (
|
|
423
|
+
<span>
|
|
424
|
+
{row.original.total_hours_per_week
|
|
425
|
+
? `${row.original.total_hours_per_week}h`
|
|
426
|
+
: "N/A"}
|
|
427
|
+
</span>
|
|
428
|
+
),
|
|
257
429
|
}),
|
|
258
430
|
];
|
|
259
431
|
|
|
432
|
+
const selectColumn = columnHelper.display({
|
|
433
|
+
id: "select",
|
|
434
|
+
header: ({ table }) => (
|
|
435
|
+
<input
|
|
436
|
+
type="checkbox"
|
|
437
|
+
checked={table.getIsAllPageRowsSelected()}
|
|
438
|
+
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
|
439
|
+
className="rounded border-gray-300"
|
|
440
|
+
/>
|
|
441
|
+
),
|
|
442
|
+
cell: ({ row }) => (
|
|
443
|
+
<input
|
|
444
|
+
type="checkbox"
|
|
445
|
+
checked={row.getIsSelected()}
|
|
446
|
+
onChange={row.getToggleSelectedHandler()}
|
|
447
|
+
className="rounded border-gray-300"
|
|
448
|
+
/>
|
|
449
|
+
),
|
|
450
|
+
size: 50,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
let dataColumns = defaultDataColumns;
|
|
454
|
+
if (Array.isArray(visibleColumns) && visibleColumns.length > 0) {
|
|
455
|
+
const mapByKey = new Map(
|
|
456
|
+
defaultDataColumns.map((c) => [c.accessorKey || c.id, c])
|
|
457
|
+
);
|
|
458
|
+
dataColumns = visibleColumns
|
|
459
|
+
.map((key) => mapByKey.get(key))
|
|
460
|
+
.filter(Boolean);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Add profile picture column if enabled
|
|
464
|
+
if (showProfilePics) {
|
|
465
|
+
dataColumns = [profilePicColumn, ...dataColumns];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const columns = multiSelect ? [selectColumn, ...dataColumns] : dataColumns;
|
|
469
|
+
|
|
260
470
|
// Create table instance
|
|
261
471
|
const table = useReactTable({
|
|
262
472
|
data: employees,
|
|
@@ -266,7 +476,9 @@ const EmployeeSearchModal = ({
|
|
|
266
476
|
state: {
|
|
267
477
|
sorting,
|
|
268
478
|
rowSelection: selectedEmployeesState.reduce((acc, selectedEmployee) => {
|
|
269
|
-
const rowIndex = employees.findIndex(
|
|
479
|
+
const rowIndex = employees.findIndex(
|
|
480
|
+
(employee) => employee.entra_id === selectedEmployee.entra_id
|
|
481
|
+
);
|
|
270
482
|
if (rowIndex !== -1) {
|
|
271
483
|
acc[rowIndex] = true;
|
|
272
484
|
}
|
|
@@ -279,13 +491,19 @@ const EmployeeSearchModal = ({
|
|
|
279
491
|
// instead of replacing it, so selection is retained across searches/pages.
|
|
280
492
|
|
|
281
493
|
// Build current selection map (for visible rows only)
|
|
282
|
-
const prevSelection = selectedEmployeesState.reduce(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
494
|
+
const prevSelection = selectedEmployeesState.reduce(
|
|
495
|
+
(acc, selectedEmployee) => {
|
|
496
|
+
const rowIndex = employees.findIndex(
|
|
497
|
+
(employee) => employee.entra_id === selectedEmployee.entra_id
|
|
498
|
+
);
|
|
499
|
+
if (rowIndex !== -1) acc[rowIndex] = true;
|
|
500
|
+
return acc;
|
|
501
|
+
},
|
|
502
|
+
{}
|
|
503
|
+
);
|
|
287
504
|
|
|
288
|
-
const updatedSelection =
|
|
505
|
+
const updatedSelection =
|
|
506
|
+
typeof updater === "function" ? updater(prevSelection) : updater;
|
|
289
507
|
|
|
290
508
|
// Determine which visible employees are selected after the change
|
|
291
509
|
const visibleSelected = Object.keys(updatedSelection)
|
|
@@ -294,11 +512,15 @@ const EmployeeSearchModal = ({
|
|
|
294
512
|
.filter(Boolean);
|
|
295
513
|
|
|
296
514
|
// Start from the previous global selection keyed by entra_id
|
|
297
|
-
const prevById = new Map(
|
|
515
|
+
const prevById = new Map(
|
|
516
|
+
selectedEmployeesState.map((e) => [e.entra_id, e])
|
|
517
|
+
);
|
|
298
518
|
|
|
299
519
|
// Remove any currently visible employees that are now unselected
|
|
300
520
|
const visibleIds = new Set(employees.map((e) => e.entra_id));
|
|
301
|
-
const visibleSelectedIds = new Set(
|
|
521
|
+
const visibleSelectedIds = new Set(
|
|
522
|
+
visibleSelected.map((e) => e.entra_id)
|
|
523
|
+
);
|
|
302
524
|
employees.forEach((e) => {
|
|
303
525
|
if (visibleIds.has(e.entra_id) && !visibleSelectedIds.has(e.entra_id)) {
|
|
304
526
|
prevById.delete(e.entra_id);
|
|
@@ -348,7 +570,34 @@ const EmployeeSearchModal = ({
|
|
|
348
570
|
setError(null);
|
|
349
571
|
|
|
350
572
|
try {
|
|
351
|
-
|
|
573
|
+
// Resolve access token for APIM
|
|
574
|
+
const apimScope = import.meta.env.VITE_APIM_SCOPE;
|
|
575
|
+
|
|
576
|
+
let accessToken = null;
|
|
577
|
+
if (typeof getAccessToken === "function") {
|
|
578
|
+
accessToken = await getAccessToken();
|
|
579
|
+
} else if (authToken) {
|
|
580
|
+
accessToken = authToken;
|
|
581
|
+
} else if (apimScope) {
|
|
582
|
+
try {
|
|
583
|
+
const response = await instance.acquireTokenSilent({
|
|
584
|
+
account: accounts[0],
|
|
585
|
+
scopes: [apimScope],
|
|
586
|
+
});
|
|
587
|
+
accessToken = response.accessToken;
|
|
588
|
+
} catch (silentErr) {
|
|
589
|
+
const response = await instance.acquireTokenPopup({
|
|
590
|
+
scopes: [apimScope],
|
|
591
|
+
});
|
|
592
|
+
accessToken = response.accessToken;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!accessToken) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
"Missing access token. Provide authToken/getAccessToken or set VITE_APIM_SCOPE."
|
|
599
|
+
);
|
|
600
|
+
}
|
|
352
601
|
|
|
353
602
|
const params = new URLSearchParams({
|
|
354
603
|
entra_id: accounts[0].localAccountId,
|
|
@@ -363,13 +612,19 @@ const EmployeeSearchModal = ({
|
|
|
363
612
|
if (siteIds && siteIds.length > 0) {
|
|
364
613
|
params.append("site_ids", siteIds.join(","));
|
|
365
614
|
} else if (debouncedAdvancedFilters.selectedSiteId) {
|
|
366
|
-
params.append(
|
|
615
|
+
params.append(
|
|
616
|
+
"site_id",
|
|
617
|
+
debouncedAdvancedFilters.selectedSiteId.toString()
|
|
618
|
+
);
|
|
367
619
|
} else if (siteId) {
|
|
368
620
|
params.append("site_id", siteId.toString());
|
|
369
621
|
}
|
|
370
622
|
|
|
371
623
|
// Handle status filtering
|
|
372
|
-
if (
|
|
624
|
+
if (
|
|
625
|
+
debouncedAdvancedFilters.status &&
|
|
626
|
+
debouncedAdvancedFilters.status !== "all"
|
|
627
|
+
) {
|
|
373
628
|
params.append("status", debouncedAdvancedFilters.status);
|
|
374
629
|
} else if (activeOnly) {
|
|
375
630
|
params.append("active_only", "true");
|
|
@@ -387,7 +642,10 @@ const EmployeeSearchModal = ({
|
|
|
387
642
|
|
|
388
643
|
// Add start date filters
|
|
389
644
|
if (debouncedAdvancedFilters.startDateFrom) {
|
|
390
|
-
params.append(
|
|
645
|
+
params.append(
|
|
646
|
+
"start_date_from",
|
|
647
|
+
debouncedAdvancedFilters.startDateFrom
|
|
648
|
+
);
|
|
391
649
|
}
|
|
392
650
|
if (debouncedAdvancedFilters.startDateTo) {
|
|
393
651
|
params.append("start_date_to", debouncedAdvancedFilters.startDateTo);
|
|
@@ -408,7 +666,10 @@ const EmployeeSearchModal = ({
|
|
|
408
666
|
|
|
409
667
|
// Add maternity leave filter
|
|
410
668
|
if (debouncedAdvancedFilters.onMaternityLeave) {
|
|
411
|
-
params.append(
|
|
669
|
+
params.append(
|
|
670
|
+
"on_maternity_leave",
|
|
671
|
+
debouncedAdvancedFilters.onMaternityLeave
|
|
672
|
+
);
|
|
412
673
|
}
|
|
413
674
|
|
|
414
675
|
// Add DBS number filter
|
|
@@ -418,7 +679,10 @@ const EmployeeSearchModal = ({
|
|
|
418
679
|
|
|
419
680
|
// Add working hours filter
|
|
420
681
|
if (debouncedAdvancedFilters.minHoursPerWeek) {
|
|
421
|
-
params.append(
|
|
682
|
+
params.append(
|
|
683
|
+
"min_hours_per_week",
|
|
684
|
+
debouncedAdvancedFilters.minHoursPerWeek
|
|
685
|
+
);
|
|
422
686
|
}
|
|
423
687
|
|
|
424
688
|
// Add sorting
|
|
@@ -426,14 +690,11 @@ const EmployeeSearchModal = ({
|
|
|
426
690
|
params.append("sort_order", debouncedAdvancedFilters.sortOrder);
|
|
427
691
|
|
|
428
692
|
const apiResponse = await fetch(
|
|
429
|
-
|
|
430
|
-
import.meta.env.VITE_COMMON_API_BASE_URL ||
|
|
431
|
-
"https://snaps-common-api.azurewebsites.net"
|
|
432
|
-
}/api/search-employees?${params}`,
|
|
693
|
+
`https://snapdragons.azure-api.net/api/employees/search-employees?${params}`,
|
|
433
694
|
{
|
|
434
695
|
headers: {
|
|
435
696
|
"Content-Type": "application/json",
|
|
436
|
-
|
|
697
|
+
Authorization: `Bearer ${accessToken}`,
|
|
437
698
|
},
|
|
438
699
|
}
|
|
439
700
|
);
|
|
@@ -449,9 +710,9 @@ const EmployeeSearchModal = ({
|
|
|
449
710
|
employees: data.data.employees.length,
|
|
450
711
|
pagination: data.data.pagination,
|
|
451
712
|
totalCount: data.data.pagination.totalCount,
|
|
452
|
-
totalPages: data.data.pagination.totalPages
|
|
713
|
+
totalPages: data.data.pagination.totalPages,
|
|
453
714
|
});
|
|
454
|
-
|
|
715
|
+
|
|
455
716
|
setEmployees(data.data.employees);
|
|
456
717
|
setPagination(data.data.pagination);
|
|
457
718
|
trackEvent("employee_search_modal_success", {
|
|
@@ -500,6 +761,17 @@ const EmployeeSearchModal = ({
|
|
|
500
761
|
}
|
|
501
762
|
}, [isOpen, instance, accounts, searchEmployees]);
|
|
502
763
|
|
|
764
|
+
// Fetch profile pictures when employees change and showProfilePics is enabled
|
|
765
|
+
useEffect(() => {
|
|
766
|
+
if (showProfilePics && employees.length > 0) {
|
|
767
|
+
employees.forEach((employee) => {
|
|
768
|
+
if (employee.entra_id) {
|
|
769
|
+
fetchProfilePicture(employee.entra_id);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}, [employees, showProfilePics, fetchProfilePicture]);
|
|
774
|
+
|
|
503
775
|
const handlePageChange = (newPage) => {
|
|
504
776
|
setPagination((prev) => ({ ...prev, page: newPage }));
|
|
505
777
|
};
|
|
@@ -562,17 +834,27 @@ const EmployeeSearchModal = ({
|
|
|
562
834
|
|
|
563
835
|
// Calculate pagination display values
|
|
564
836
|
const actualTotalCount = pagination.totalCount || employees.length;
|
|
565
|
-
const startItem =
|
|
566
|
-
|
|
837
|
+
const startItem =
|
|
838
|
+
actualTotalCount > 0 ? (pagination.page - 1) * pagination.pageSize + 1 : 0;
|
|
839
|
+
const endItem = Math.min(
|
|
840
|
+
pagination.page * pagination.pageSize,
|
|
841
|
+
actualTotalCount
|
|
842
|
+
);
|
|
567
843
|
|
|
568
844
|
if (!isOpen) return null;
|
|
569
845
|
|
|
570
846
|
return (
|
|
571
|
-
<div className="fixed inset-0
|
|
847
|
+
<div className="fixed inset-0 overflow-y-auto" style={{ zIndex }}>
|
|
572
848
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
|
573
|
-
<div
|
|
849
|
+
<div
|
|
850
|
+
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
851
|
+
onClick={onClose}
|
|
852
|
+
></div>
|
|
574
853
|
|
|
575
|
-
<div
|
|
854
|
+
<div
|
|
855
|
+
className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-7xl flex flex-col"
|
|
856
|
+
style={{ maxHeight: `${maxHeightVh}vh` }}
|
|
857
|
+
>
|
|
576
858
|
{/* Header */}
|
|
577
859
|
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
578
860
|
<div className="flex items-center justify-between">
|
|
@@ -590,7 +872,7 @@ const EmployeeSearchModal = ({
|
|
|
590
872
|
</div>
|
|
591
873
|
|
|
592
874
|
{/* Content */}
|
|
593
|
-
<div className="px-6 py-4
|
|
875
|
+
<div className="px-6 py-4 overflow-y-auto flex-1">
|
|
594
876
|
{/* Search Input */}
|
|
595
877
|
<div className="mb-4">
|
|
596
878
|
<Input
|
|
@@ -612,7 +894,9 @@ const EmployeeSearchModal = ({
|
|
|
612
894
|
managers={managers}
|
|
613
895
|
activeOnly={activeOnly}
|
|
614
896
|
isAdvancedFiltersOpen={isAdvancedFiltersOpen}
|
|
615
|
-
onToggleAdvancedFilters={() =>
|
|
897
|
+
onToggleAdvancedFilters={() =>
|
|
898
|
+
setIsAdvancedFiltersOpen(!isAdvancedFiltersOpen)
|
|
899
|
+
}
|
|
616
900
|
onClearFilters={clearFilters}
|
|
617
901
|
showRoleFilter={showRoleFilter}
|
|
618
902
|
showManagerFilter={showManagerFilter}
|
|
@@ -645,7 +929,7 @@ const EmployeeSearchModal = ({
|
|
|
645
929
|
{/* Results Header */}
|
|
646
930
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
647
931
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
648
|
-
{
|
|
932
|
+
{pagination.totalCount > 0 || employees.length > 0
|
|
649
933
|
? `Showing ${startItem} to ${endItem} of ${actualTotalCount} employees`
|
|
650
934
|
: "No employees found"}
|
|
651
935
|
</div>
|
|
@@ -677,7 +961,9 @@ const EmployeeSearchModal = ({
|
|
|
677
961
|
<TableRow
|
|
678
962
|
key={row.id}
|
|
679
963
|
data-state={row.getIsSelected() && "selected"}
|
|
680
|
-
onClick={() =>
|
|
964
|
+
onClick={() =>
|
|
965
|
+
handleEmployeeSelect(row.original)
|
|
966
|
+
}
|
|
681
967
|
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
682
968
|
>
|
|
683
969
|
{row.getVisibleCells().map((cell) => (
|
|
@@ -721,7 +1007,7 @@ const EmployeeSearchModal = ({
|
|
|
721
1007
|
<Pagination>
|
|
722
1008
|
<PaginationContent>
|
|
723
1009
|
<PaginationItem>
|
|
724
|
-
<PaginationPrevious
|
|
1010
|
+
<PaginationPrevious
|
|
725
1011
|
href="#"
|
|
726
1012
|
onClick={(e) => {
|
|
727
1013
|
e.preventDefault();
|
|
@@ -743,7 +1029,10 @@ const EmployeeSearchModal = ({
|
|
|
743
1029
|
)
|
|
744
1030
|
.filter((page) => {
|
|
745
1031
|
const current = pagination.page;
|
|
746
|
-
const total = Math.max(
|
|
1032
|
+
const total = Math.max(
|
|
1033
|
+
pagination.totalPages,
|
|
1034
|
+
1
|
|
1035
|
+
);
|
|
747
1036
|
return (
|
|
748
1037
|
page === 1 ||
|
|
749
1038
|
page === total ||
|
|
@@ -751,7 +1040,10 @@ const EmployeeSearchModal = ({
|
|
|
751
1040
|
);
|
|
752
1041
|
})
|
|
753
1042
|
.map((page, index, array) => {
|
|
754
|
-
if (
|
|
1043
|
+
if (
|
|
1044
|
+
index > 0 &&
|
|
1045
|
+
array[index - 1] !== page - 1
|
|
1046
|
+
) {
|
|
755
1047
|
return (
|
|
756
1048
|
<React.Fragment key={`ellipsis-${page}`}>
|
|
757
1049
|
<PaginationItem>
|
|
@@ -789,7 +1081,7 @@ const EmployeeSearchModal = ({
|
|
|
789
1081
|
})}
|
|
790
1082
|
|
|
791
1083
|
<PaginationItem>
|
|
792
|
-
<PaginationNext
|
|
1084
|
+
<PaginationNext
|
|
793
1085
|
href="#"
|
|
794
1086
|
onClick={(e) => {
|
|
795
1087
|
e.preventDefault();
|
|
@@ -818,15 +1110,21 @@ const EmployeeSearchModal = ({
|
|
|
818
1110
|
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-3 flex items-center justify-between gap-3">
|
|
819
1111
|
{/* Selection preview */}
|
|
820
1112
|
<div className="flex-1 min-w-0">
|
|
821
|
-
{multiSelect &&
|
|
1113
|
+
{multiSelect &&
|
|
1114
|
+
selectedEmployeesState.length > 0 &&
|
|
822
1115
|
(() => {
|
|
823
1116
|
const total = selectedEmployeesState.length;
|
|
824
1117
|
const preview = selectedEmployeesState.slice(0, 5);
|
|
825
1118
|
const extra = Math.max(total - preview.length, 0);
|
|
826
|
-
const previewText =
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1119
|
+
const previewText =
|
|
1120
|
+
`${total} selected${
|
|
1121
|
+
maxSelections ? ` / ${maxSelections}` : ""
|
|
1122
|
+
}: ` +
|
|
1123
|
+
preview.map((e) => e.full_name).join(", ") +
|
|
1124
|
+
(extra > 0 ? ` + ${extra} more` : "");
|
|
1125
|
+
const fullList = selectedEmployeesState
|
|
1126
|
+
.map((e) => e.full_name)
|
|
1127
|
+
.join(", ");
|
|
830
1128
|
return (
|
|
831
1129
|
<div
|
|
832
1130
|
className="text-sm text-gray-600 dark:text-gray-300 truncate"
|
|
@@ -835,12 +1133,15 @@ const EmployeeSearchModal = ({
|
|
|
835
1133
|
{previewText}
|
|
836
1134
|
</div>
|
|
837
1135
|
);
|
|
838
|
-
})()
|
|
839
|
-
)}
|
|
1136
|
+
})()}
|
|
840
1137
|
</div>
|
|
841
1138
|
<div className="flex items-center gap-2">
|
|
842
1139
|
{multiSelect && (
|
|
843
|
-
<Button
|
|
1140
|
+
<Button
|
|
1141
|
+
variant="outline"
|
|
1142
|
+
onClick={handleClearSelection}
|
|
1143
|
+
disabled={selectedEmployeesState.length === 0}
|
|
1144
|
+
>
|
|
844
1145
|
Clear
|
|
845
1146
|
</Button>
|
|
846
1147
|
)}
|
|
@@ -848,12 +1149,14 @@ const EmployeeSearchModal = ({
|
|
|
848
1149
|
Go Back
|
|
849
1150
|
</Button>
|
|
850
1151
|
{multiSelect && (
|
|
851
|
-
<Button
|
|
852
|
-
onClick={handleConfirmSelection}
|
|
1152
|
+
<Button
|
|
1153
|
+
onClick={handleConfirmSelection}
|
|
853
1154
|
disabled={selectedEmployeesState.length === 0}
|
|
854
1155
|
aria-label={`Confirm ${selectedEmployeesState.length} selected`}
|
|
855
1156
|
>
|
|
856
|
-
{`Confirm (${selectedEmployeesState.length}${
|
|
1157
|
+
{`Confirm (${selectedEmployeesState.length}${
|
|
1158
|
+
maxSelections ? `/${maxSelections}` : ""
|
|
1159
|
+
})`}
|
|
857
1160
|
</Button>
|
|
858
1161
|
)}
|
|
859
1162
|
</div>
|
|
@@ -864,4 +1167,4 @@ const EmployeeSearchModal = ({
|
|
|
864
1167
|
);
|
|
865
1168
|
};
|
|
866
1169
|
|
|
867
|
-
export default EmployeeSearchModal;
|
|
1170
|
+
export default EmployeeSearchModal;
|