@snapdragonsnursery/react-components 1.3.0 → 1.3.2
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/EmployeeSearchDemo.jsx +275 -0
- package/src/EmployeeSearchModal.jsx +817 -0
- package/src/EmployeeSearchPage.jsx +804 -0
- package/src/EmployeeSearchPage.test.jsx +240 -0
- package/src/components/EmployeeSearchFilters.jsx +418 -0
- package/src/components/EmployeeSearchFilters.test.jsx +546 -0
- package/src/index.js +7 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
// Employee Search Modal Component
|
|
2
|
+
// Provides a modal interface for employee search with advanced filtering and selection capabilities
|
|
3
|
+
// Usage: <EmployeeSearchModal isOpen={isOpen} onClose={onClose} onSelect={handleSelect} />
|
|
4
|
+
|
|
5
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
6
|
+
import { useMsal } from "@azure/msal-react";
|
|
7
|
+
import {
|
|
8
|
+
useReactTable,
|
|
9
|
+
getCoreRowModel,
|
|
10
|
+
getSortedRowModel,
|
|
11
|
+
flexRender,
|
|
12
|
+
createColumnHelper,
|
|
13
|
+
} from "@tanstack/react-table";
|
|
14
|
+
import { trackEvent } from "./telemetry";
|
|
15
|
+
import { Button } from "./components/ui/button";
|
|
16
|
+
import { Input } from "./components/ui/input";
|
|
17
|
+
import {
|
|
18
|
+
Table,
|
|
19
|
+
TableBody,
|
|
20
|
+
TableCell,
|
|
21
|
+
TableHead,
|
|
22
|
+
TableHeader,
|
|
23
|
+
TableRow,
|
|
24
|
+
} from "./components/ui/table";
|
|
25
|
+
import {
|
|
26
|
+
Pagination,
|
|
27
|
+
PaginationContent,
|
|
28
|
+
PaginationEllipsis,
|
|
29
|
+
PaginationItem,
|
|
30
|
+
PaginationLink,
|
|
31
|
+
PaginationNext,
|
|
32
|
+
PaginationPrevious,
|
|
33
|
+
} from "./components/ui/pagination";
|
|
34
|
+
import {
|
|
35
|
+
CheckCircleIcon,
|
|
36
|
+
XCircleIcon,
|
|
37
|
+
ChevronUpIcon,
|
|
38
|
+
ChevronDownIcon,
|
|
39
|
+
XMarkIcon,
|
|
40
|
+
} from "@heroicons/react/24/outline";
|
|
41
|
+
import { cn } from "./lib/utils";
|
|
42
|
+
import EmployeeSearchFilters from "./components/EmployeeSearchFilters";
|
|
43
|
+
|
|
44
|
+
const EmployeeSearchModal = ({
|
|
45
|
+
isOpen = false,
|
|
46
|
+
onClose,
|
|
47
|
+
onSelect,
|
|
48
|
+
title = "Select Employee",
|
|
49
|
+
searchPlaceholder = "Search by name, employee ID, or email...",
|
|
50
|
+
siteId = null,
|
|
51
|
+
siteIds = null,
|
|
52
|
+
sites = null,
|
|
53
|
+
roles = null,
|
|
54
|
+
managers = null,
|
|
55
|
+
activeOnly = true,
|
|
56
|
+
status = null,
|
|
57
|
+
startDateFrom = null,
|
|
58
|
+
startDateTo = null,
|
|
59
|
+
endDateFrom = null,
|
|
60
|
+
endDateTo = null,
|
|
61
|
+
sortBy = "surname",
|
|
62
|
+
sortOrder = "asc",
|
|
63
|
+
applicationContext = "employee-search-modal",
|
|
64
|
+
bypassPermissions = false,
|
|
65
|
+
multiSelect = false,
|
|
66
|
+
maxSelections = null,
|
|
67
|
+
selectedEmployees = [],
|
|
68
|
+
// Configurable filter visibility
|
|
69
|
+
showRoleFilter = true,
|
|
70
|
+
showManagerFilter = true,
|
|
71
|
+
showTermTimeFilter = true,
|
|
72
|
+
showMaternityFilter = true,
|
|
73
|
+
showStartDateFilter = true,
|
|
74
|
+
showEndDateFilter = true,
|
|
75
|
+
showDbsFilter = true,
|
|
76
|
+
showWorkingHoursFilter = true,
|
|
77
|
+
}) => {
|
|
78
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
79
|
+
const [employees, setEmployees] = useState([]);
|
|
80
|
+
const [loading, setLoading] = useState(false);
|
|
81
|
+
const [error, setError] = useState(null);
|
|
82
|
+
const [pagination, setPagination] = useState({
|
|
83
|
+
page: 1,
|
|
84
|
+
pageSize: 20,
|
|
85
|
+
totalCount: 0,
|
|
86
|
+
totalPages: 0,
|
|
87
|
+
hasNextPage: false,
|
|
88
|
+
hasPreviousPage: false,
|
|
89
|
+
});
|
|
90
|
+
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
|
91
|
+
const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState(false);
|
|
92
|
+
const [advancedFilters, setAdvancedFilters] = useState({
|
|
93
|
+
status: status || (activeOnly ? "Active" : "all"),
|
|
94
|
+
selectedSiteId: siteId || "",
|
|
95
|
+
roleId: "",
|
|
96
|
+
managerId: "",
|
|
97
|
+
startDateFrom: startDateFrom || "",
|
|
98
|
+
startDateTo: startDateTo || "",
|
|
99
|
+
endDateFrom: endDateFrom || "",
|
|
100
|
+
endDateTo: endDateTo || "",
|
|
101
|
+
termTimeOnly: "",
|
|
102
|
+
onMaternityLeave: "",
|
|
103
|
+
dbsNumber: "",
|
|
104
|
+
minHoursPerWeek: "",
|
|
105
|
+
sortBy: sortBy,
|
|
106
|
+
sortOrder: sortOrder,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Table sorting state
|
|
110
|
+
const [sorting, setSorting] = useState([]);
|
|
111
|
+
const [debouncedAdvancedFilters, setDebouncedAdvancedFilters] = useState(advancedFilters);
|
|
112
|
+
|
|
113
|
+
// State for multi-select mode
|
|
114
|
+
const [selectedEmployeesState, setSelectedEmployeesState] = useState(
|
|
115
|
+
selectedEmployees || []
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const { instance, accounts } = useMsal();
|
|
119
|
+
|
|
120
|
+
// Column helper for TanStack table
|
|
121
|
+
const columnHelper = createColumnHelper();
|
|
122
|
+
|
|
123
|
+
// Helper function to create sortable header
|
|
124
|
+
const createSortableHeader = (column, label) => {
|
|
125
|
+
return ({ column: tableColumn }) => {
|
|
126
|
+
const isSorted = tableColumn.getIsSorted();
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
className="flex items-center space-x-1 cursor-pointer select-none"
|
|
130
|
+
onClick={tableColumn.getToggleSortingHandler()}
|
|
131
|
+
>
|
|
132
|
+
<span>{label}</span>
|
|
133
|
+
<div className="flex flex-col">
|
|
134
|
+
<ChevronUpIcon
|
|
135
|
+
className={cn(
|
|
136
|
+
"h-3 w-3 transition-colors",
|
|
137
|
+
isSorted === "asc" ? "text-blue-500" : "text-gray-400"
|
|
138
|
+
)}
|
|
139
|
+
/>
|
|
140
|
+
<ChevronDownIcon
|
|
141
|
+
className={cn(
|
|
142
|
+
"h-3 w-3 transition-colors -mt-1",
|
|
143
|
+
isSorted === "desc" ? "text-blue-500" : "text-gray-400"
|
|
144
|
+
)}
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Define table columns
|
|
153
|
+
const columns = [
|
|
154
|
+
// Checkbox column for multi-select
|
|
155
|
+
...(multiSelect
|
|
156
|
+
? [
|
|
157
|
+
columnHelper.display({
|
|
158
|
+
id: "select",
|
|
159
|
+
header: ({ table }) => (
|
|
160
|
+
<input
|
|
161
|
+
type="checkbox"
|
|
162
|
+
checked={table.getIsAllPageRowsSelected()}
|
|
163
|
+
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
|
164
|
+
className="rounded border-gray-300"
|
|
165
|
+
/>
|
|
166
|
+
),
|
|
167
|
+
cell: ({ row }) => (
|
|
168
|
+
<input
|
|
169
|
+
type="checkbox"
|
|
170
|
+
checked={row.getIsSelected()}
|
|
171
|
+
onChange={row.getToggleSelectedHandler()}
|
|
172
|
+
className="rounded border-gray-300"
|
|
173
|
+
/>
|
|
174
|
+
),
|
|
175
|
+
size: 50,
|
|
176
|
+
}),
|
|
177
|
+
]
|
|
178
|
+
: []),
|
|
179
|
+
// Name column - sortable
|
|
180
|
+
columnHelper.accessor("full_name", {
|
|
181
|
+
header: createSortableHeader("full_name", "Name"),
|
|
182
|
+
cell: ({ row }) => (
|
|
183
|
+
<div>
|
|
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>
|
|
189
|
+
),
|
|
190
|
+
}),
|
|
191
|
+
// Site column - sortable
|
|
192
|
+
columnHelper.accessor("site_name", {
|
|
193
|
+
header: createSortableHeader("site_name", "Site"),
|
|
194
|
+
cell: ({ row }) => (
|
|
195
|
+
<span>{row.original.site_name}</span>
|
|
196
|
+
),
|
|
197
|
+
}),
|
|
198
|
+
// Role column - sortable
|
|
199
|
+
columnHelper.accessor("role_name", {
|
|
200
|
+
header: createSortableHeader("role_name", "Role"),
|
|
201
|
+
cell: ({ row }) => (
|
|
202
|
+
<span>{row.original.role_name}</span>
|
|
203
|
+
),
|
|
204
|
+
}),
|
|
205
|
+
// Email column
|
|
206
|
+
columnHelper.accessor("email", {
|
|
207
|
+
header: "Email",
|
|
208
|
+
cell: ({ row }) => (
|
|
209
|
+
<span className="text-sm">{row.original.email}</span>
|
|
210
|
+
),
|
|
211
|
+
}),
|
|
212
|
+
// Start Date column - sortable
|
|
213
|
+
columnHelper.accessor("start_date", {
|
|
214
|
+
header: createSortableHeader("start_date", "Start Date"),
|
|
215
|
+
cell: ({ row }) => (
|
|
216
|
+
<span>
|
|
217
|
+
{row.original.start_date
|
|
218
|
+
? new Date(row.original.start_date).toLocaleDateString("en-GB")
|
|
219
|
+
: "N/A"}
|
|
220
|
+
</span>
|
|
221
|
+
),
|
|
222
|
+
}),
|
|
223
|
+
// Status column - sortable
|
|
224
|
+
columnHelper.accessor("employee_status", {
|
|
225
|
+
header: createSortableHeader("employee_status", "Status"),
|
|
226
|
+
cell: ({ row }) => {
|
|
227
|
+
const status = row.original.employee_status;
|
|
228
|
+
const statusColors = {
|
|
229
|
+
'Active': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
230
|
+
'Inactive': 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
231
|
+
'On Leave': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
232
|
+
'Terminated': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<span
|
|
237
|
+
className={cn(
|
|
238
|
+
"px-2 py-1 text-xs rounded-full",
|
|
239
|
+
statusColors[status] || "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200"
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{status || "Unknown"}
|
|
243
|
+
</span>
|
|
244
|
+
);
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
// Working Hours column
|
|
248
|
+
columnHelper.accessor("total_hours_per_week", {
|
|
249
|
+
header: "Hours/Week",
|
|
250
|
+
cell: ({ row }) => (
|
|
251
|
+
<span>
|
|
252
|
+
{row.original.total_hours_per_week
|
|
253
|
+
? `${row.original.total_hours_per_week}h`
|
|
254
|
+
: "N/A"}
|
|
255
|
+
</span>
|
|
256
|
+
),
|
|
257
|
+
}),
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
// Create table instance
|
|
261
|
+
const table = useReactTable({
|
|
262
|
+
data: employees,
|
|
263
|
+
columns,
|
|
264
|
+
getCoreRowModel: getCoreRowModel(),
|
|
265
|
+
getSortedRowModel: getSortedRowModel(),
|
|
266
|
+
state: {
|
|
267
|
+
sorting,
|
|
268
|
+
rowSelection: selectedEmployeesState.reduce((acc, selectedEmployee) => {
|
|
269
|
+
const rowIndex = employees.findIndex(employee => employee.entra_id === selectedEmployee.entra_id);
|
|
270
|
+
if (rowIndex !== -1) {
|
|
271
|
+
acc[rowIndex] = true;
|
|
272
|
+
}
|
|
273
|
+
return acc;
|
|
274
|
+
}, {}),
|
|
275
|
+
},
|
|
276
|
+
onSortingChange: setSorting,
|
|
277
|
+
onRowSelectionChange: (updater) => {
|
|
278
|
+
const newSelection =
|
|
279
|
+
typeof updater === "function" ? updater({}) : updater;
|
|
280
|
+
const selectedRows = Object.keys(newSelection).map(
|
|
281
|
+
(index) => employees[parseInt(index)]
|
|
282
|
+
);
|
|
283
|
+
setSelectedEmployeesState(selectedRows);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Debounce search term
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
const timer = setTimeout(() => {
|
|
290
|
+
setDebouncedSearchTerm(searchTerm);
|
|
291
|
+
setPagination((prev) => ({ ...prev, page: 1 }));
|
|
292
|
+
}, 300);
|
|
293
|
+
|
|
294
|
+
return () => clearTimeout(timer);
|
|
295
|
+
}, [searchTerm]);
|
|
296
|
+
|
|
297
|
+
// Debounce advanced filters
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
setDebouncedAdvancedFilters(advancedFilters);
|
|
301
|
+
setPagination((prev) => ({ ...prev, page: 1 }));
|
|
302
|
+
}, 500); // Slightly longer delay for filters
|
|
303
|
+
|
|
304
|
+
return () => clearTimeout(timer);
|
|
305
|
+
}, [advancedFilters]);
|
|
306
|
+
|
|
307
|
+
// Search employees
|
|
308
|
+
const searchEmployees = useCallback(async () => {
|
|
309
|
+
if (!instance || !accounts[0]) {
|
|
310
|
+
setError("Authentication required");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
setLoading(true);
|
|
315
|
+
setError(null);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const functionKey = import.meta.env.VITE_COMMON_API_FUNCTION_KEY || "";
|
|
319
|
+
|
|
320
|
+
const params = new URLSearchParams({
|
|
321
|
+
entra_id: accounts[0].localAccountId,
|
|
322
|
+
search_term: debouncedSearchTerm,
|
|
323
|
+
page: pagination.page,
|
|
324
|
+
page_size: pagination.pageSize,
|
|
325
|
+
application_context: applicationContext,
|
|
326
|
+
bypass_permissions: bypassPermissions.toString(),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Handle site filtering
|
|
330
|
+
if (siteIds && siteIds.length > 0) {
|
|
331
|
+
params.append("site_ids", siteIds.join(","));
|
|
332
|
+
} else if (debouncedAdvancedFilters.selectedSiteId) {
|
|
333
|
+
params.append("site_id", debouncedAdvancedFilters.selectedSiteId.toString());
|
|
334
|
+
} else if (siteId) {
|
|
335
|
+
params.append("site_id", siteId.toString());
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Handle status filtering
|
|
339
|
+
if (debouncedAdvancedFilters.status && debouncedAdvancedFilters.status !== "all") {
|
|
340
|
+
params.append("status", debouncedAdvancedFilters.status);
|
|
341
|
+
} else if (activeOnly) {
|
|
342
|
+
params.append("active_only", "true");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Add role filter
|
|
346
|
+
if (debouncedAdvancedFilters.roleId) {
|
|
347
|
+
params.append("role_id", debouncedAdvancedFilters.roleId);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Add manager filter
|
|
351
|
+
if (debouncedAdvancedFilters.managerId) {
|
|
352
|
+
params.append("manager_id", debouncedAdvancedFilters.managerId);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Add start date filters
|
|
356
|
+
if (debouncedAdvancedFilters.startDateFrom) {
|
|
357
|
+
params.append("start_date_from", debouncedAdvancedFilters.startDateFrom);
|
|
358
|
+
}
|
|
359
|
+
if (debouncedAdvancedFilters.startDateTo) {
|
|
360
|
+
params.append("start_date_to", debouncedAdvancedFilters.startDateTo);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Add end date filters
|
|
364
|
+
if (debouncedAdvancedFilters.endDateFrom) {
|
|
365
|
+
params.append("end_date_from", debouncedAdvancedFilters.endDateFrom);
|
|
366
|
+
}
|
|
367
|
+
if (debouncedAdvancedFilters.endDateTo) {
|
|
368
|
+
params.append("end_date_to", debouncedAdvancedFilters.endDateTo);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Add term time only filter
|
|
372
|
+
if (debouncedAdvancedFilters.termTimeOnly) {
|
|
373
|
+
params.append("term_time_only", debouncedAdvancedFilters.termTimeOnly);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Add maternity leave filter
|
|
377
|
+
if (debouncedAdvancedFilters.onMaternityLeave) {
|
|
378
|
+
params.append("on_maternity_leave", debouncedAdvancedFilters.onMaternityLeave);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Add DBS number filter
|
|
382
|
+
if (debouncedAdvancedFilters.dbsNumber) {
|
|
383
|
+
params.append("dbs_number", debouncedAdvancedFilters.dbsNumber);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Add working hours filter
|
|
387
|
+
if (debouncedAdvancedFilters.minHoursPerWeek) {
|
|
388
|
+
params.append("min_hours_per_week", debouncedAdvancedFilters.minHoursPerWeek);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Add sorting
|
|
392
|
+
params.append("sort_by", debouncedAdvancedFilters.sortBy);
|
|
393
|
+
params.append("sort_order", debouncedAdvancedFilters.sortOrder);
|
|
394
|
+
|
|
395
|
+
const apiResponse = await fetch(
|
|
396
|
+
`${
|
|
397
|
+
import.meta.env.VITE_COMMON_API_BASE_URL ||
|
|
398
|
+
"https://snaps-common-api.azurewebsites.net"
|
|
399
|
+
}/api/search-employees?${params}`,
|
|
400
|
+
{
|
|
401
|
+
headers: {
|
|
402
|
+
"Content-Type": "application/json",
|
|
403
|
+
"x-functions-key": functionKey,
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
if (!apiResponse.ok) {
|
|
409
|
+
throw new Error(`API request failed: ${apiResponse.status}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const data = await apiResponse.json();
|
|
413
|
+
|
|
414
|
+
if (data.success) {
|
|
415
|
+
console.log("🔍 API Response:", {
|
|
416
|
+
employees: data.data.employees.length,
|
|
417
|
+
pagination: data.data.pagination,
|
|
418
|
+
totalCount: data.data.pagination.totalCount,
|
|
419
|
+
totalPages: data.data.pagination.totalPages
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
setEmployees(data.data.employees);
|
|
423
|
+
setPagination(data.data.pagination);
|
|
424
|
+
trackEvent("employee_search_modal_success", {
|
|
425
|
+
searchTerm: debouncedSearchTerm,
|
|
426
|
+
resultCount: data.data.employees.length,
|
|
427
|
+
totalCount: data.data.pagination.totalCount,
|
|
428
|
+
});
|
|
429
|
+
} else {
|
|
430
|
+
throw new Error(data.error || "Search failed");
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error("Error searching employees:", err);
|
|
434
|
+
setError(err.message);
|
|
435
|
+
trackEvent("employee_search_modal_error", {
|
|
436
|
+
error: err.message,
|
|
437
|
+
searchTerm: debouncedSearchTerm,
|
|
438
|
+
});
|
|
439
|
+
} finally {
|
|
440
|
+
setLoading(false);
|
|
441
|
+
}
|
|
442
|
+
}, [
|
|
443
|
+
instance,
|
|
444
|
+
accounts,
|
|
445
|
+
debouncedSearchTerm,
|
|
446
|
+
pagination.page,
|
|
447
|
+
pagination.pageSize,
|
|
448
|
+
siteId,
|
|
449
|
+
siteIds,
|
|
450
|
+
activeOnly,
|
|
451
|
+
debouncedAdvancedFilters,
|
|
452
|
+
applicationContext,
|
|
453
|
+
bypassPermissions,
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
// Search when debounced term changes
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
if (isOpen) {
|
|
459
|
+
searchEmployees();
|
|
460
|
+
}
|
|
461
|
+
}, [debouncedSearchTerm, pagination.page, searchEmployees, isOpen]);
|
|
462
|
+
|
|
463
|
+
// Initial search on modal open
|
|
464
|
+
useEffect(() => {
|
|
465
|
+
if (isOpen && instance && accounts[0]) {
|
|
466
|
+
searchEmployees();
|
|
467
|
+
}
|
|
468
|
+
}, [isOpen, instance, accounts, searchEmployees]);
|
|
469
|
+
|
|
470
|
+
const handlePageChange = (newPage) => {
|
|
471
|
+
setPagination((prev) => ({ ...prev, page: newPage }));
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const handleEmployeeSelect = (employee) => {
|
|
475
|
+
if (multiSelect) {
|
|
476
|
+
const isAlreadySelected = selectedEmployeesState.some(
|
|
477
|
+
(selected) => selected.entra_id === employee.entra_id
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
if (isAlreadySelected) {
|
|
481
|
+
const updatedSelection = selectedEmployeesState.filter(
|
|
482
|
+
(selected) => selected.entra_id !== employee.entra_id
|
|
483
|
+
);
|
|
484
|
+
setSelectedEmployeesState(updatedSelection);
|
|
485
|
+
} else {
|
|
486
|
+
if (maxSelections && selectedEmployeesState.length >= maxSelections) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const updatedSelection = [...selectedEmployeesState, employee];
|
|
490
|
+
setSelectedEmployeesState(updatedSelection);
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
onSelect(employee);
|
|
494
|
+
onClose();
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const handleConfirmSelection = () => {
|
|
499
|
+
if (multiSelect && selectedEmployeesState.length > 0) {
|
|
500
|
+
onSelect(selectedEmployeesState);
|
|
501
|
+
onClose();
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const clearFilters = () => {
|
|
506
|
+
const clearedFilters = {
|
|
507
|
+
status: activeOnly ? "Active" : "all",
|
|
508
|
+
selectedSiteId: "",
|
|
509
|
+
roleId: "",
|
|
510
|
+
managerId: "",
|
|
511
|
+
startDateFrom: "",
|
|
512
|
+
startDateTo: "",
|
|
513
|
+
endDateFrom: "",
|
|
514
|
+
endDateTo: "",
|
|
515
|
+
termTimeOnly: "",
|
|
516
|
+
onMaternityLeave: "",
|
|
517
|
+
dbsNumber: "",
|
|
518
|
+
minHoursPerWeek: "",
|
|
519
|
+
sortBy: "surname",
|
|
520
|
+
sortOrder: "asc",
|
|
521
|
+
};
|
|
522
|
+
setAdvancedFilters(clearedFilters);
|
|
523
|
+
setDebouncedAdvancedFilters(clearedFilters); // Clear immediately
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// Calculate pagination display values
|
|
527
|
+
const actualTotalCount = pagination.totalCount || employees.length;
|
|
528
|
+
const startItem = actualTotalCount > 0 ? (pagination.page - 1) * pagination.pageSize + 1 : 0;
|
|
529
|
+
const endItem = Math.min(pagination.page * pagination.pageSize, actualTotalCount);
|
|
530
|
+
|
|
531
|
+
if (!isOpen) return null;
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
535
|
+
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
|
536
|
+
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
|
|
537
|
+
|
|
538
|
+
<div 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">
|
|
539
|
+
{/* Header */}
|
|
540
|
+
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
541
|
+
<div className="flex items-center justify-between">
|
|
542
|
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
543
|
+
{title}
|
|
544
|
+
</h3>
|
|
545
|
+
<button
|
|
546
|
+
onClick={onClose}
|
|
547
|
+
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
548
|
+
>
|
|
549
|
+
<XMarkIcon className="h-6 w-6" />
|
|
550
|
+
</button>
|
|
551
|
+
</div>
|
|
552
|
+
{multiSelect && (
|
|
553
|
+
<div className="mt-2 flex items-center justify-between">
|
|
554
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
555
|
+
{selectedEmployeesState.length} selected
|
|
556
|
+
{maxSelections && ` / ${maxSelections}`}
|
|
557
|
+
</span>
|
|
558
|
+
{selectedEmployeesState.length > 0 && (
|
|
559
|
+
<Button
|
|
560
|
+
size="sm"
|
|
561
|
+
onClick={handleConfirmSelection}
|
|
562
|
+
>
|
|
563
|
+
Confirm Selection
|
|
564
|
+
</Button>
|
|
565
|
+
)}
|
|
566
|
+
</div>
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
{/* Content */}
|
|
571
|
+
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">
|
|
572
|
+
{/* Search Input */}
|
|
573
|
+
<div className="mb-4">
|
|
574
|
+
<Input
|
|
575
|
+
type="text"
|
|
576
|
+
placeholder={searchPlaceholder}
|
|
577
|
+
value={searchTerm}
|
|
578
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
579
|
+
className="w-full"
|
|
580
|
+
/>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
{/* Advanced Filters */}
|
|
584
|
+
<EmployeeSearchFilters
|
|
585
|
+
filters={advancedFilters}
|
|
586
|
+
onFiltersChange={setAdvancedFilters}
|
|
587
|
+
onApplyFilters={setAdvancedFilters}
|
|
588
|
+
sites={sites}
|
|
589
|
+
roles={roles}
|
|
590
|
+
managers={managers}
|
|
591
|
+
activeOnly={activeOnly}
|
|
592
|
+
isAdvancedFiltersOpen={isAdvancedFiltersOpen}
|
|
593
|
+
onToggleAdvancedFilters={() => setIsAdvancedFiltersOpen(!isAdvancedFiltersOpen)}
|
|
594
|
+
onClearFilters={clearFilters}
|
|
595
|
+
showRoleFilter={showRoleFilter}
|
|
596
|
+
showManagerFilter={showManagerFilter}
|
|
597
|
+
showTermTimeFilter={showTermTimeFilter}
|
|
598
|
+
showMaternityFilter={showMaternityFilter}
|
|
599
|
+
showStartDateFilter={showStartDateFilter}
|
|
600
|
+
showEndDateFilter={showEndDateFilter}
|
|
601
|
+
showDbsFilter={showDbsFilter}
|
|
602
|
+
showWorkingHoursFilter={showWorkingHoursFilter}
|
|
603
|
+
/>
|
|
604
|
+
|
|
605
|
+
{/* Results */}
|
|
606
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
607
|
+
{loading && (
|
|
608
|
+
<div className="flex items-center justify-center p-8">
|
|
609
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
|
610
|
+
</div>
|
|
611
|
+
)}
|
|
612
|
+
|
|
613
|
+
{error && (
|
|
614
|
+
<div className="p-6 text-center">
|
|
615
|
+
<div className="text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
|
616
|
+
{error}
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
)}
|
|
620
|
+
|
|
621
|
+
{!loading && !error && (
|
|
622
|
+
<>
|
|
623
|
+
{/* Results Header */}
|
|
624
|
+
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
625
|
+
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
626
|
+
{(pagination.totalCount > 0 || employees.length > 0)
|
|
627
|
+
? `Showing ${startItem} to ${endItem} of ${actualTotalCount} employees`
|
|
628
|
+
: "No employees found"}
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
{/* Table */}
|
|
633
|
+
{employees.length > 0 ? (
|
|
634
|
+
<div className="overflow-x-auto">
|
|
635
|
+
<Table>
|
|
636
|
+
<TableHeader>
|
|
637
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
638
|
+
<TableRow key={headerGroup.id}>
|
|
639
|
+
{headerGroup.headers.map((header) => (
|
|
640
|
+
<TableHead key={header.id}>
|
|
641
|
+
{header.isPlaceholder
|
|
642
|
+
? null
|
|
643
|
+
: flexRender(
|
|
644
|
+
header.column.columnDef.header,
|
|
645
|
+
header.getContext()
|
|
646
|
+
)}
|
|
647
|
+
</TableHead>
|
|
648
|
+
))}
|
|
649
|
+
</TableRow>
|
|
650
|
+
))}
|
|
651
|
+
</TableHeader>
|
|
652
|
+
<TableBody>
|
|
653
|
+
{table.getRowModel().rows?.length ? (
|
|
654
|
+
table.getRowModel().rows.map((row) => (
|
|
655
|
+
<TableRow
|
|
656
|
+
key={row.id}
|
|
657
|
+
data-state={row.getIsSelected() && "selected"}
|
|
658
|
+
onClick={() => handleEmployeeSelect(row.original)}
|
|
659
|
+
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
660
|
+
>
|
|
661
|
+
{row.getVisibleCells().map((cell) => (
|
|
662
|
+
<TableCell key={cell.id}>
|
|
663
|
+
{flexRender(
|
|
664
|
+
cell.column.columnDef.cell,
|
|
665
|
+
cell.getContext()
|
|
666
|
+
)}
|
|
667
|
+
</TableCell>
|
|
668
|
+
))}
|
|
669
|
+
</TableRow>
|
|
670
|
+
))
|
|
671
|
+
) : (
|
|
672
|
+
<TableRow>
|
|
673
|
+
<TableCell
|
|
674
|
+
colSpan={columns.length}
|
|
675
|
+
className="h-24 text-center"
|
|
676
|
+
>
|
|
677
|
+
{debouncedSearchTerm
|
|
678
|
+
? "No employees found matching your search."
|
|
679
|
+
: "Start typing to search for employees."}
|
|
680
|
+
</TableCell>
|
|
681
|
+
</TableRow>
|
|
682
|
+
)}
|
|
683
|
+
</TableBody>
|
|
684
|
+
</Table>
|
|
685
|
+
</div>
|
|
686
|
+
) : (
|
|
687
|
+
<div className="p-6 text-center text-gray-500 dark:text-gray-400">
|
|
688
|
+
{debouncedSearchTerm
|
|
689
|
+
? "No employees found matching your search."
|
|
690
|
+
: "Start typing to search for employees."}
|
|
691
|
+
</div>
|
|
692
|
+
)}
|
|
693
|
+
|
|
694
|
+
{/* Pagination */}
|
|
695
|
+
{(pagination.totalCount > 0 || employees.length > 0) && (
|
|
696
|
+
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
|
697
|
+
{(pagination.totalPages > 1 || employees.length > 0) && (
|
|
698
|
+
<div className="flex justify-center">
|
|
699
|
+
<Pagination>
|
|
700
|
+
<PaginationContent>
|
|
701
|
+
<PaginationItem>
|
|
702
|
+
<PaginationPrevious
|
|
703
|
+
href="#"
|
|
704
|
+
onClick={(e) => {
|
|
705
|
+
e.preventDefault();
|
|
706
|
+
if (pagination.hasPreviousPage) {
|
|
707
|
+
handlePageChange(pagination.page - 1);
|
|
708
|
+
}
|
|
709
|
+
}}
|
|
710
|
+
className={cn(
|
|
711
|
+
!pagination.hasPreviousPage &&
|
|
712
|
+
"pointer-events-none opacity-50"
|
|
713
|
+
)}
|
|
714
|
+
/>
|
|
715
|
+
</PaginationItem>
|
|
716
|
+
|
|
717
|
+
{/* Page numbers */}
|
|
718
|
+
{Array.from(
|
|
719
|
+
{ length: Math.max(pagination.totalPages, 1) },
|
|
720
|
+
(_, i) => i + 1
|
|
721
|
+
)
|
|
722
|
+
.filter((page) => {
|
|
723
|
+
const current = pagination.page;
|
|
724
|
+
const total = Math.max(pagination.totalPages, 1);
|
|
725
|
+
return (
|
|
726
|
+
page === 1 ||
|
|
727
|
+
page === total ||
|
|
728
|
+
(page >= current - 1 && page <= current + 1)
|
|
729
|
+
);
|
|
730
|
+
})
|
|
731
|
+
.map((page, index, array) => {
|
|
732
|
+
if (index > 0 && array[index - 1] !== page - 1) {
|
|
733
|
+
return (
|
|
734
|
+
<React.Fragment key={`ellipsis-${page}`}>
|
|
735
|
+
<PaginationItem>
|
|
736
|
+
<PaginationEllipsis />
|
|
737
|
+
</PaginationItem>
|
|
738
|
+
<PaginationItem>
|
|
739
|
+
<PaginationLink
|
|
740
|
+
href="#"
|
|
741
|
+
onClick={(e) => {
|
|
742
|
+
e.preventDefault();
|
|
743
|
+
handlePageChange(page);
|
|
744
|
+
}}
|
|
745
|
+
isActive={page === pagination.page}
|
|
746
|
+
>
|
|
747
|
+
{page}
|
|
748
|
+
</PaginationLink>
|
|
749
|
+
</PaginationItem>
|
|
750
|
+
</React.Fragment>
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
return (
|
|
754
|
+
<PaginationItem key={page}>
|
|
755
|
+
<PaginationLink
|
|
756
|
+
href="#"
|
|
757
|
+
onClick={(e) => {
|
|
758
|
+
e.preventDefault();
|
|
759
|
+
handlePageChange(page);
|
|
760
|
+
}}
|
|
761
|
+
isActive={page === pagination.page}
|
|
762
|
+
>
|
|
763
|
+
{page}
|
|
764
|
+
</PaginationLink>
|
|
765
|
+
</PaginationItem>
|
|
766
|
+
);
|
|
767
|
+
})}
|
|
768
|
+
|
|
769
|
+
<PaginationItem>
|
|
770
|
+
<PaginationNext
|
|
771
|
+
href="#"
|
|
772
|
+
onClick={(e) => {
|
|
773
|
+
e.preventDefault();
|
|
774
|
+
if (pagination.hasNextPage) {
|
|
775
|
+
handlePageChange(pagination.page + 1);
|
|
776
|
+
}
|
|
777
|
+
}}
|
|
778
|
+
className={cn(
|
|
779
|
+
!pagination.hasNextPage &&
|
|
780
|
+
"pointer-events-none opacity-50"
|
|
781
|
+
)}
|
|
782
|
+
/>
|
|
783
|
+
</PaginationItem>
|
|
784
|
+
</PaginationContent>
|
|
785
|
+
</Pagination>
|
|
786
|
+
</div>
|
|
787
|
+
)}
|
|
788
|
+
</div>
|
|
789
|
+
)}
|
|
790
|
+
</>
|
|
791
|
+
)}
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
|
|
795
|
+
{/* Footer */}
|
|
796
|
+
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-3 flex justify-end space-x-3">
|
|
797
|
+
<Button
|
|
798
|
+
variant="outline"
|
|
799
|
+
onClick={onClose}
|
|
800
|
+
>
|
|
801
|
+
Cancel
|
|
802
|
+
</Button>
|
|
803
|
+
{multiSelect && selectedEmployeesState.length > 0 && (
|
|
804
|
+
<Button
|
|
805
|
+
onClick={handleConfirmSelection}
|
|
806
|
+
>
|
|
807
|
+
Select {selectedEmployeesState.length} Employee{selectedEmployeesState.length !== 1 ? 's' : ''}
|
|
808
|
+
</Button>
|
|
809
|
+
)}
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
</div>
|
|
813
|
+
</div>
|
|
814
|
+
);
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
export default EmployeeSearchModal;
|