@snapdragonsnursery/react-components 1.13.0 → 1.15.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
@@ -188,16 +188,16 @@ The `ChildSearchFilters` component now includes an "Apply Filters" button that p
188
188
 
189
189
  ## Environment Variables
190
190
 
191
- Set these environment variables in your application:
191
+ Set this environment variable in your application:
192
192
 
193
193
  ```env
194
- VITE_COMMON_API_BASE_URL=https://snaps-common-api.azurewebsites.net
194
+ VITE_APIM_SCOPE=api://your-apim-app-id/.default
195
195
  ```
196
196
 
197
197
  ## Documentation
198
198
 
199
- - [ChildSearchModal Documentation](./CHILD_SEARCH_MODAL_DOCUMENTATION.md)
200
- - [ChildSearchModal README](./CHILD_SEARCH_README.md)
199
+ - [ChildSearchModal Documentation](docs/CHILD_SEARCH_MODAL_DOCUMENTATION.md)
200
+ - [ChildSearchModal README](docs/CHILD_SEARCH_README.md)
201
201
  - [Release Guide](./RELEASE.md)
202
202
  - [SoftWarningAlert](./SOFT_WARNING_ALERT.md)
203
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapdragonsnursery/react-components",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -26,6 +26,12 @@ const ChildSearchModal = ({
26
26
  multiSelect = false, // Enable multiple child selection
27
27
  maxSelections = null, // Maximum number of children that can be selected (null = unlimited)
28
28
  selectedChildren = [], // Array of already selected children (for multi-select mode)
29
+ // Auth options: provide token directly or a function to fetch it
30
+ authToken = null,
31
+ getAccessToken = null,
32
+ // Layout controls
33
+ zIndex = 60,
34
+ maxHeightVh = 90,
29
35
  }) => {
30
36
  const [searchTerm, setSearchTerm] = useState("");
31
37
  const [children, setChildren] = useState([]);
@@ -137,8 +143,35 @@ const ChildSearchModal = ({
137
143
  setError(null);
138
144
 
139
145
  try {
140
- // Get function key for authentication
141
- const functionKey = import.meta.env.VITE_COMMON_API_FUNCTION_KEY || "";
146
+ // Resolve access token: prefer prop function, then prop token, then MSAL using APIM scope
147
+ const apimScope = import.meta.env.VITE_APIM_SCOPE;
148
+
149
+ let accessToken = null;
150
+ if (typeof getAccessToken === "function") {
151
+ accessToken = await getAccessToken();
152
+ } else if (authToken) {
153
+ accessToken = authToken;
154
+ } else if (apimScope) {
155
+ try {
156
+ const response = await instance.acquireTokenSilent({
157
+ account: accounts[0],
158
+ scopes: [apimScope],
159
+ });
160
+ accessToken = response.accessToken;
161
+ } catch (silentErr) {
162
+ // Fallback to popup if silent acquisition fails
163
+ const response = await instance.acquireTokenPopup({
164
+ scopes: [apimScope],
165
+ });
166
+ accessToken = response.accessToken;
167
+ }
168
+ }
169
+
170
+ if (!accessToken) {
171
+ throw new Error(
172
+ "Missing access token. Provide authToken/getAccessToken or set VITE_APIM_SCOPE."
173
+ );
174
+ }
142
175
 
143
176
  // Build query parameters
144
177
  const params = new URLSearchParams({
@@ -186,16 +219,13 @@ const ChildSearchModal = ({
186
219
  params.append("sort_by", advancedFilters.sortBy);
187
220
  params.append("sort_order", advancedFilters.sortOrder);
188
221
 
189
- // Make API call
222
+ // Make API call via APIM with Bearer token
190
223
  const apiResponse = await fetch(
191
- `${
192
- import.meta.env.VITE_COMMON_API_BASE_URL ||
193
- "https://snaps-common-api.azurewebsites.net"
194
- }/api/search-children?${params}`,
224
+ `https://snapdragons.azure-api.net/api/children/search-children?${params}`,
195
225
  {
196
226
  headers: {
197
227
  "Content-Type": "application/json",
198
- "x-functions-key": functionKey,
228
+ Authorization: `Bearer ${accessToken}`,
199
229
  },
200
230
  }
201
231
  );
@@ -351,13 +381,13 @@ const ChildSearchModal = ({
351
381
  if (!isOpen) return null;
352
382
 
353
383
  return (
354
- <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-hidden">
384
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 overflow-hidden" style={{ zIndex }}>
355
385
  <div
356
386
  ref={modalRef}
357
387
  className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full flex flex-col overflow-hidden ${className}`}
358
388
  style={{
359
- maxHeight: '90vh',
360
- height: '90vh',
389
+ maxHeight: `${maxHeightVh}vh`,
390
+ height: `${maxHeightVh}vh`,
361
391
  display: 'flex',
362
392
  flexDirection: 'column',
363
393
  position: 'relative'
@@ -61,6 +61,9 @@ const ChildSearchPage = ({
61
61
  multiSelect = false,
62
62
  maxSelections = null,
63
63
  selectedChildren = [],
64
+ // Auth options: provide token directly or a function to fetch it
65
+ authToken = null,
66
+ getAccessToken = null,
64
67
  }) => {
65
68
  const [searchTerm, setSearchTerm] = useState("");
66
69
  const [children, setChildren] = useState([]);
@@ -286,7 +289,34 @@ const ChildSearchPage = ({
286
289
  setError(null);
287
290
 
288
291
  try {
289
- const functionKey = import.meta.env.VITE_COMMON_API_FUNCTION_KEY || "";
292
+ // Resolve access token for APIM
293
+ const apimScope = import.meta.env.VITE_APIM_SCOPE;
294
+
295
+ let accessToken = null;
296
+ if (typeof getAccessToken === "function") {
297
+ accessToken = await getAccessToken();
298
+ } else if (authToken) {
299
+ accessToken = authToken;
300
+ } else if (apimScope) {
301
+ try {
302
+ const response = await instance.acquireTokenSilent({
303
+ account: accounts[0],
304
+ scopes: [apimScope],
305
+ });
306
+ accessToken = response.accessToken;
307
+ } catch (silentErr) {
308
+ const response = await instance.acquireTokenPopup({
309
+ scopes: [apimScope],
310
+ });
311
+ accessToken = response.accessToken;
312
+ }
313
+ }
314
+
315
+ if (!accessToken) {
316
+ throw new Error(
317
+ "Missing access token. Provide authToken/getAccessToken or set VITE_APIM_SCOPE."
318
+ );
319
+ }
290
320
 
291
321
  const params = new URLSearchParams({
292
322
  entra_id: accounts[0].localAccountId,
@@ -334,14 +364,11 @@ const ChildSearchPage = ({
334
364
  params.append("sort_order", debouncedAdvancedFilters.sortOrder);
335
365
 
336
366
  const apiResponse = await fetch(
337
- `${
338
- import.meta.env.VITE_COMMON_API_BASE_URL ||
339
- "https://snaps-common-api.azurewebsites.net"
340
- }/api/search-children?${params}`,
367
+ `https://snapdragons.azure-api.net/api/children/search-children?${params}`,
341
368
  {
342
369
  headers: {
343
370
  "Content-Type": "application/json",
344
- "x-functions-key": functionKey,
371
+ Authorization: `Bearer ${accessToken}`,
345
372
  },
346
373
  }
347
374
  );
@@ -174,6 +174,42 @@ const EmployeeSearchDemo = () => {
174
174
  showWorkingHoursFilter={true}
175
175
  />
176
176
  </div>
177
+
178
+ {/* Custom Columns (Page) */}
179
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
180
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
181
+ Custom Columns (Page)
182
+ </h3>
183
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
184
+ Demonstrates visibleColumns, columnLabels, and columnRenderers.
185
+ </p>
186
+ <EmployeeSearchPage
187
+ title="Custom Column Set"
188
+ onSelect={handleEmployeeSelect}
189
+ sites={mockSites}
190
+ roles={mockRoles}
191
+ managers={mockManagers}
192
+ visibleColumns={[
193
+ 'full_name',
194
+ 'site_name',
195
+ 'role_name',
196
+ 'email',
197
+ 'employee_status',
198
+ ]}
199
+ columnLabels={{
200
+ full_name: 'Employee',
201
+ site_name: 'Location',
202
+ role_name: 'Position',
203
+ }}
204
+ columnRenderers={{
205
+ email: (row) => (
206
+ <a href={`mailto:${row.email}`} className="text-blue-600">
207
+ {row.email}
208
+ </a>
209
+ ),
210
+ }}
211
+ />
212
+ </div>
177
213
  </div>
178
214
 
179
215
  {/* Site-Specific Search */}
@@ -272,4 +308,4 @@ const EmployeeSearchDemo = () => {
272
308
  );
273
309
  };
274
310
 
275
- export default EmployeeSearchDemo;
311
+ export default EmployeeSearchDemo;
@@ -74,6 +74,16 @@ const EmployeeSearchModal = ({
74
74
  showEndDateFilter = true,
75
75
  showDbsFilter = true,
76
76
  showWorkingHoursFilter = true,
77
+ // Layout controls
78
+ zIndex = 60,
79
+ maxHeightVh = 85,
80
+ // Auth options: provide token directly or a function to fetch it
81
+ authToken = null,
82
+ getAccessToken = null,
83
+ // Column customization
84
+ visibleColumns = null,
85
+ columnLabels = {},
86
+ columnRenderers = {},
77
87
  }) => {
78
88
  const [searchTerm, setSearchTerm] = useState("");
79
89
  const [employees, setEmployees] = useState([]);
@@ -149,114 +159,127 @@ const EmployeeSearchModal = ({
149
159
  };
150
160
  };
151
161
 
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
162
+ // Define default data columns (excluding optional select)
163
+ const defaultDataColumns = [
180
164
  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}
165
+ header: createSortableHeader("full_name", columnLabels.full_name || "Name"),
166
+ cell: ({ row }) =>
167
+ columnRenderers.full_name ? (
168
+ columnRenderers.full_name(row.original)
169
+ ) : (
170
+ <div>
171
+ <div className="font-medium">{row.original.full_name}</div>
172
+ <div className="text-sm text-gray-500">ID: {row.original.employee_id}</div>
187
173
  </div>
188
- </div>
189
- ),
174
+ ),
190
175
  }),
191
- // Site column - sortable
192
176
  columnHelper.accessor("site_name", {
193
- header: createSortableHeader("site_name", "Site"),
194
- cell: ({ row }) => (
195
- <span>{row.original.site_name}</span>
196
- ),
177
+ header: createSortableHeader("site_name", columnLabels.site_name || "Site"),
178
+ cell: ({ row }) =>
179
+ columnRenderers.site_name ? (
180
+ columnRenderers.site_name(row.original)
181
+ ) : (
182
+ <span>{row.original.site_name}</span>
183
+ ),
197
184
  }),
198
- // Role column - sortable
199
185
  columnHelper.accessor("role_name", {
200
- header: createSortableHeader("role_name", "Role"),
201
- cell: ({ row }) => (
202
- <span>{row.original.role_name}</span>
203
- ),
186
+ header: createSortableHeader("role_name", columnLabels.role_name || "Role"),
187
+ cell: ({ row }) =>
188
+ columnRenderers.role_name ? (
189
+ columnRenderers.role_name(row.original)
190
+ ) : (
191
+ <span>{row.original.role_name}</span>
192
+ ),
204
193
  }),
205
- // Email column
206
194
  columnHelper.accessor("email", {
207
- header: "Email",
208
- cell: ({ row }) => (
209
- <span className="text-sm">{row.original.email}</span>
210
- ),
195
+ header: columnLabels.email || "Email",
196
+ cell: ({ row }) =>
197
+ columnRenderers.email ? (
198
+ columnRenderers.email(row.original)
199
+ ) : (
200
+ <span className="text-sm">{row.original.email}</span>
201
+ ),
211
202
  }),
212
- // Start Date column - sortable
213
203
  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
- ),
204
+ header: createSortableHeader("start_date", columnLabels.start_date || "Start Date"),
205
+ cell: ({ row }) =>
206
+ columnRenderers.start_date ? (
207
+ columnRenderers.start_date(row.original)
208
+ ) : (
209
+ <span>
210
+ {row.original.start_date
211
+ ? new Date(row.original.start_date).toLocaleDateString("en-GB")
212
+ : "N/A"}
213
+ </span>
214
+ ),
222
215
  }),
223
- // Status column - sortable
224
216
  columnHelper.accessor("employee_status", {
225
- header: createSortableHeader("employee_status", "Status"),
217
+ header: createSortableHeader("employee_status", columnLabels.employee_status || "Status"),
226
218
  cell: ({ row }) => {
219
+ if (columnRenderers.employee_status) return columnRenderers.employee_status(row.original);
227
220
  const status = row.original.employee_status;
228
221
  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',
222
+ Active: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
223
+ Inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
231
224
  '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',
225
+ Terminated: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
233
226
  };
234
-
235
227
  return (
236
228
  <span
237
229
  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"
230
+ 'px-2 py-1 text-xs rounded-full',
231
+ statusColors[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'
240
232
  )}
241
233
  >
242
- {status || "Unknown"}
234
+ {status || 'Unknown'}
243
235
  </span>
244
236
  );
245
237
  },
246
238
  }),
247
- // Working Hours column
248
239
  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
- ),
240
+ header: columnLabels.total_hours_per_week || "Hours/Week",
241
+ cell: ({ row }) =>
242
+ columnRenderers.total_hours_per_week ? (
243
+ columnRenderers.total_hours_per_week(row.original)
244
+ ) : (
245
+ <span>
246
+ {row.original.total_hours_per_week
247
+ ? `${row.original.total_hours_per_week}h`
248
+ : "N/A"}
249
+ </span>
250
+ ),
257
251
  }),
258
252
  ];
259
253
 
254
+ const selectColumn = columnHelper.display({
255
+ id: "select",
256
+ header: ({ table }) => (
257
+ <input
258
+ type="checkbox"
259
+ checked={table.getIsAllPageRowsSelected()}
260
+ onChange={table.getToggleAllPageRowsSelectedHandler()}
261
+ className="rounded border-gray-300"
262
+ />
263
+ ),
264
+ cell: ({ row }) => (
265
+ <input
266
+ type="checkbox"
267
+ checked={row.getIsSelected()}
268
+ onChange={row.getToggleSelectedHandler()}
269
+ className="rounded border-gray-300"
270
+ />
271
+ ),
272
+ size: 50,
273
+ });
274
+
275
+ let dataColumns = defaultDataColumns;
276
+ if (Array.isArray(visibleColumns) && visibleColumns.length > 0) {
277
+ const mapByKey = new Map(defaultDataColumns.map((c) => [c.accessorKey || c.id, c]));
278
+ dataColumns = visibleColumns.map((key) => mapByKey.get(key)).filter(Boolean);
279
+ }
280
+
281
+ const columns = multiSelect ? [selectColumn, ...dataColumns] : dataColumns;
282
+
260
283
  // Create table instance
261
284
  const table = useReactTable({
262
285
  data: employees,
@@ -348,7 +371,34 @@ const EmployeeSearchModal = ({
348
371
  setError(null);
349
372
 
350
373
  try {
351
- const functionKey = import.meta.env.VITE_COMMON_API_FUNCTION_KEY || "";
374
+ // Resolve access token for APIM
375
+ const apimScope = import.meta.env.VITE_APIM_SCOPE;
376
+
377
+ let accessToken = null;
378
+ if (typeof getAccessToken === "function") {
379
+ accessToken = await getAccessToken();
380
+ } else if (authToken) {
381
+ accessToken = authToken;
382
+ } else if (apimScope) {
383
+ try {
384
+ const response = await instance.acquireTokenSilent({
385
+ account: accounts[0],
386
+ scopes: [apimScope],
387
+ });
388
+ accessToken = response.accessToken;
389
+ } catch (silentErr) {
390
+ const response = await instance.acquireTokenPopup({
391
+ scopes: [apimScope],
392
+ });
393
+ accessToken = response.accessToken;
394
+ }
395
+ }
396
+
397
+ if (!accessToken) {
398
+ throw new Error(
399
+ "Missing access token. Provide authToken/getAccessToken or set VITE_APIM_SCOPE."
400
+ );
401
+ }
352
402
 
353
403
  const params = new URLSearchParams({
354
404
  entra_id: accounts[0].localAccountId,
@@ -426,14 +476,11 @@ const EmployeeSearchModal = ({
426
476
  params.append("sort_order", debouncedAdvancedFilters.sortOrder);
427
477
 
428
478
  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}`,
479
+ `https://snapdragons.azure-api.net/api/employees/search-employees?${params}`,
433
480
  {
434
481
  headers: {
435
482
  "Content-Type": "application/json",
436
- "x-functions-key": functionKey,
483
+ Authorization: `Bearer ${accessToken}`,
437
484
  },
438
485
  }
439
486
  );
@@ -568,11 +615,14 @@ const EmployeeSearchModal = ({
568
615
  if (!isOpen) return null;
569
616
 
570
617
  return (
571
- <div className="fixed inset-0 z-50 overflow-y-auto">
618
+ <div className="fixed inset-0 overflow-y-auto" style={{ zIndex }}>
572
619
  <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
573
620
  <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
574
621
 
575
- <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">
622
+ <div
623
+ 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"
624
+ style={{ maxHeight: `${maxHeightVh}vh` }}
625
+ >
576
626
  {/* Header */}
577
627
  <div className="bg-white dark:bg-gray-800 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
578
628
  <div className="flex items-center justify-between">
@@ -590,7 +640,7 @@ const EmployeeSearchModal = ({
590
640
  </div>
591
641
 
592
642
  {/* Content */}
593
- <div className="px-6 py-4 max-h-[70vh] overflow-y-auto">
643
+ <div className="px-6 py-4 overflow-y-auto flex-1">
594
644
  {/* Search Input */}
595
645
  <div className="mb-4">
596
646
  <Input
@@ -72,6 +72,13 @@ const EmployeeSearchPage = ({
72
72
  showEndDateFilter = false,
73
73
  showWorkingHoursFilter = true,
74
74
  loadAllResults = false, // If true, loads all results instead of paginating
75
+ // Auth options: provide token directly or a function to fetch it
76
+ authToken = null,
77
+ getAccessToken = null,
78
+ // Column customization
79
+ visibleColumns = null, // e.g. ['full_name','site_name','role_name','email','start_date','employee_status','total_hours_per_week']
80
+ columnLabels = {}, // e.g. { full_name: 'Employee', site_name: 'Location' }
81
+ columnRenderers = {}, // e.g. { email: (row) => <a>{row.email}</a> }
75
82
  }) => {
76
83
  const [searchTerm, setSearchTerm] = useState("");
77
84
  const [employees, setEmployees] = useState([]);
@@ -157,98 +164,80 @@ const EmployeeSearchPage = ({
157
164
  };
158
165
  };
159
166
 
160
- // Define table columns
161
- const columns = [
162
- // Checkbox column for multi-select
163
- ...(multiSelect
164
- ? [
165
- columnHelper.display({
166
- id: "select",
167
- header: ({ table }) => (
168
- <input
169
- type="checkbox"
170
- checked={table.getIsAllPageRowsSelected()}
171
- onChange={table.getToggleAllPageRowsSelectedHandler()}
172
- className="rounded border-gray-300"
173
- />
174
- ),
175
- cell: ({ row }) => (
176
- <input
177
- type="checkbox"
178
- checked={row.getIsSelected()}
179
- onChange={row.getToggleSelectedHandler()}
180
- className="rounded border-gray-300"
181
- />
182
- ),
183
- size: 50,
184
- }),
185
- ]
186
- : []),
187
- // Name column - sortable
167
+ // Define default data columns (excluding the optional select column)
168
+ const defaultDataColumns = [
188
169
  columnHelper.accessor("full_name", {
189
- header: createSortableHeader("full_name", "Name"),
190
- cell: ({ row }) => (
191
- <div>
192
- <div className="font-medium">{row.original.full_name}</div>
193
- <div className="text-sm text-gray-500">
194
- ID: {row.original.employee_id}
170
+ header: createSortableHeader("full_name", columnLabels.full_name || "Name"),
171
+ cell: ({ row }) =>
172
+ columnRenderers.full_name ? (
173
+ columnRenderers.full_name(row.original)
174
+ ) : (
175
+ <div>
176
+ <div className="font-medium">{row.original.full_name}</div>
177
+ <div className="text-sm text-gray-500">ID: {row.original.employee_id}</div>
195
178
  </div>
196
- </div>
197
- ),
179
+ ),
198
180
  }),
199
- // Site column - sortable
200
181
  columnHelper.accessor("site_name", {
201
- header: createSortableHeader("site_name", "Site"),
202
- cell: ({ row }) => (
203
- <span>{row.original.site_name}</span>
204
- ),
182
+ header: createSortableHeader("site_name", columnLabels.site_name || "Site"),
183
+ cell: ({ row }) =>
184
+ columnRenderers.site_name ? (
185
+ columnRenderers.site_name(row.original)
186
+ ) : (
187
+ <span>{row.original.site_name}</span>
188
+ ),
205
189
  }),
206
- // Role column - sortable
207
190
  columnHelper.accessor("role_name", {
208
- header: createSortableHeader("role_name", "Role"),
209
- cell: ({ row }) => (
210
- <span>{row.original.role_name}</span>
211
- ),
191
+ header: createSortableHeader("role_name", columnLabels.role_name || "Role"),
192
+ cell: ({ row }) =>
193
+ columnRenderers.role_name ? (
194
+ columnRenderers.role_name(row.original)
195
+ ) : (
196
+ <span>{row.original.role_name}</span>
197
+ ),
212
198
  }),
213
- // Manager column - sortable
214
199
  columnHelper.accessor("manager_name", {
215
- header: createSortableHeader("manager_name", "Manager"),
216
- cell: ({ row }) => (
217
- <span className="text-sm">
218
- {row.original.manager_name || "N/A"}
219
- </span>
220
- ),
200
+ header: createSortableHeader("manager_name", columnLabels.manager_name || "Manager"),
201
+ cell: ({ row }) =>
202
+ columnRenderers.manager_name ? (
203
+ columnRenderers.manager_name(row.original)
204
+ ) : (
205
+ <span className="text-sm">{row.original.manager_name || "N/A"}</span>
206
+ ),
221
207
  }),
222
- // Email column
223
208
  columnHelper.accessor("email", {
224
- header: "Email",
225
- cell: ({ row }) => (
226
- <span className="text-sm">{row.original.email}</span>
227
- ),
209
+ header: columnLabels.email || "Email",
210
+ cell: ({ row }) =>
211
+ columnRenderers.email ? (
212
+ columnRenderers.email(row.original)
213
+ ) : (
214
+ <span className="text-sm">{row.original.email}</span>
215
+ ),
228
216
  }),
229
- // Start Date column - sortable
230
217
  columnHelper.accessor("start_date", {
231
- header: createSortableHeader("start_date", "Start Date"),
232
- cell: ({ row }) => (
233
- <span>
234
- {row.original.start_date
235
- ? new Date(row.original.start_date).toLocaleDateString("en-GB")
236
- : "N/A"}
237
- </span>
238
- ),
218
+ header: createSortableHeader("start_date", columnLabels.start_date || "Start Date"),
219
+ cell: ({ row }) =>
220
+ columnRenderers.start_date ? (
221
+ columnRenderers.start_date(row.original)
222
+ ) : (
223
+ <span>
224
+ {row.original.start_date
225
+ ? new Date(row.original.start_date).toLocaleDateString("en-GB")
226
+ : "N/A"}
227
+ </span>
228
+ ),
239
229
  }),
240
- // Status column - sortable
241
230
  columnHelper.accessor("employee_status", {
242
- header: createSortableHeader("employee_status", "Status"),
231
+ header: createSortableHeader("employee_status", columnLabels.employee_status || "Status"),
243
232
  cell: ({ row }) => {
233
+ if (columnRenderers.employee_status) return columnRenderers.employee_status(row.original);
244
234
  const status = row.original.employee_status;
245
235
  const statusColors = {
246
- 'Active': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
247
- 'Inactive': 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
248
- 'On Leave': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
249
- 'Terminated': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
236
+ Active: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
237
+ Inactive: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
238
+ "On Leave": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
239
+ Terminated: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
250
240
  };
251
-
252
241
  return (
253
242
  <span
254
243
  className={cn(
@@ -261,45 +250,86 @@ const EmployeeSearchPage = ({
261
250
  );
262
251
  },
263
252
  }),
264
- // Working Hours column
265
253
  columnHelper.accessor("total_hours_per_week", {
266
- header: "Hours/Week",
267
- cell: ({ row }) => (
268
- <span>
269
- {row.original.total_hours_per_week
270
- ? `${row.original.total_hours_per_week}h`
271
- : "N/A"}
272
- </span>
273
- ),
254
+ header: columnLabels.total_hours_per_week || "Hours/Week",
255
+ cell: ({ row }) =>
256
+ columnRenderers.total_hours_per_week ? (
257
+ columnRenderers.total_hours_per_week(row.original)
258
+ ) : (
259
+ <span>
260
+ {row.original.total_hours_per_week
261
+ ? `${row.original.total_hours_per_week}h`
262
+ : "N/A"}
263
+ </span>
264
+ ),
274
265
  }),
275
- // Term Time Only column
276
266
  columnHelper.accessor("term_time_only", {
277
- header: "Term Time",
278
- cell: ({ row }) => (
279
- <span>
280
- {row.original.term_time_only ? (
281
- <CheckCircleIcon className="h-4 w-4 text-green-500" />
282
- ) : (
283
- <XCircleIcon className="h-4 w-4 text-gray-400" />
284
- )}
285
- </span>
286
- ),
267
+ header: columnLabels.term_time_only || "Term Time",
268
+ cell: ({ row }) =>
269
+ columnRenderers.term_time_only ? (
270
+ columnRenderers.term_time_only(row.original)
271
+ ) : (
272
+ <span>
273
+ {row.original.term_time_only ? (
274
+ <CheckCircleIcon className="h-4 w-4 text-green-500" />
275
+ ) : (
276
+ <XCircleIcon className="h-4 w-4 text-gray-400" />
277
+ )}
278
+ </span>
279
+ ),
287
280
  }),
288
- // Maternity Leave column
289
281
  columnHelper.accessor("on_maternity_leave", {
290
- header: "Maternity",
291
- cell: ({ row }) => (
292
- <span>
293
- {row.original.on_maternity_leave ? (
294
- <CheckCircleIcon className="h-4 w-4 text-blue-500" />
295
- ) : (
296
- <XCircleIcon className="h-4 w-4 text-gray-400" />
297
- )}
298
- </span>
299
- ),
282
+ header: columnLabels.on_maternity_leave || "Maternity",
283
+ cell: ({ row }) =>
284
+ columnRenderers.on_maternity_leave ? (
285
+ columnRenderers.on_maternity_leave(row.original)
286
+ ) : (
287
+ <span>
288
+ {row.original.on_maternity_leave ? (
289
+ <CheckCircleIcon className="h-4 w-4 text-blue-500" />
290
+ ) : (
291
+ <XCircleIcon className="h-4 w-4 text-gray-400" />
292
+ )}
293
+ </span>
294
+ ),
300
295
  }),
301
296
  ];
302
297
 
298
+ // Build final columns order
299
+ const selectColumn = columnHelper.display({
300
+ id: "select",
301
+ header: ({ table }) => (
302
+ <input
303
+ type="checkbox"
304
+ checked={table.getIsAllPageRowsSelected()}
305
+ onChange={table.getToggleAllPageRowsSelectedHandler()}
306
+ className="rounded border-gray-300"
307
+ />
308
+ ),
309
+ cell: ({ row }) => (
310
+ <input
311
+ type="checkbox"
312
+ checked={row.getIsSelected()}
313
+ onChange={row.getToggleSelectedHandler()}
314
+ className="rounded border-gray-300"
315
+ />
316
+ ),
317
+ size: 50,
318
+ });
319
+
320
+ // Filter/reorder if visibleColumns provided
321
+ let dataColumns = defaultDataColumns;
322
+ if (Array.isArray(visibleColumns) && visibleColumns.length > 0) {
323
+ const mapByKey = new Map(
324
+ defaultDataColumns.map((c) => [c.accessorKey || c.id, c])
325
+ );
326
+ dataColumns = visibleColumns
327
+ .map((key) => mapByKey.get(key))
328
+ .filter(Boolean);
329
+ }
330
+
331
+ const columns = multiSelect ? [selectColumn, ...dataColumns] : dataColumns;
332
+
303
333
  // Create table instance
304
334
  const table = useReactTable({
305
335
  data: employees,
@@ -367,7 +397,34 @@ const EmployeeSearchPage = ({
367
397
  setError(null);
368
398
 
369
399
  try {
370
- const functionKey = import.meta.env.VITE_COMMON_API_FUNCTION_KEY || "";
400
+ // Resolve access token for APIM
401
+ const apimScope = import.meta.env.VITE_APIM_SCOPE;
402
+
403
+ let accessToken = null;
404
+ if (typeof getAccessToken === "function") {
405
+ accessToken = await getAccessToken();
406
+ } else if (authToken) {
407
+ accessToken = authToken;
408
+ } else if (apimScope) {
409
+ try {
410
+ const response = await instance.acquireTokenSilent({
411
+ account: accounts[0],
412
+ scopes: [apimScope],
413
+ });
414
+ accessToken = response.accessToken;
415
+ } catch (silentErr) {
416
+ const response = await instance.acquireTokenPopup({
417
+ scopes: [apimScope],
418
+ });
419
+ accessToken = response.accessToken;
420
+ }
421
+ }
422
+
423
+ if (!accessToken) {
424
+ throw new Error(
425
+ "Missing access token. Provide authToken/getAccessToken or set VITE_APIM_SCOPE."
426
+ );
427
+ }
371
428
 
372
429
  const params = new URLSearchParams({
373
430
  entra_id: accounts[0].localAccountId,
@@ -445,14 +502,11 @@ const EmployeeSearchPage = ({
445
502
  params.append("sort_order", sortOrder);
446
503
 
447
504
  const apiResponse = await fetch(
448
- `${
449
- import.meta.env.VITE_COMMON_API_BASE_URL ||
450
- "https://snaps-common-api.azurewebsites.net"
451
- }/api/search-employees?${params}`,
505
+ `https://snapdragons.azure-api.net/api/employees/search-employees?${params}`,
452
506
  {
453
507
  headers: {
454
508
  "Content-Type": "application/json",
455
- "x-functions-key": functionKey,
509
+ Authorization: `Bearer ${accessToken}`,
456
510
  },
457
511
  }
458
512
  );
@@ -752,35 +806,36 @@ const EmployeeSearchPage = ({
752
806
  <thead className="[&_tr]:border-b">
753
807
  {table.getHeaderGroups().map((headerGroup) => (
754
808
  <tr key={headerGroup.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
755
- {headerGroup.headers.map((header, index) => {
756
- // Define generous column widths to avoid truncation - table will scroll if too wide
757
- const columnWidths = {
758
- 0: multiSelect ? '50px' : '250px', // Select checkbox or Name
759
- 1: multiSelect ? '250px' : '180px', // Name or Site
760
- 2: multiSelect ? '180px' : '150px', // Site or Role
761
- 3: multiSelect ? '150px' : '180px', // Role or Manager
762
- 4: multiSelect ? '180px' : '300px', // Manager or Email
763
- 5: multiSelect ? '300px' : '150px', // Email or Start Date
764
- 6: multiSelect ? '150px' : '120px', // Start Date or Status
765
- 7: multiSelect ? '120px' : '100px', // Status or Hours/Week
766
- 8: multiSelect ? '100px' : '90px', // Hours/Week or Term Time
767
- 9: multiSelect ? '90px' : '90px', // Term Time or Maternity
768
- 10: '90px' // Maternity (only if multiSelect)
809
+ {headerGroup.headers.map((header) => {
810
+ // Widths keyed by column id/accessor for stability when columns are customized
811
+ const defaultWidthByKey = {
812
+ select: '50px',
813
+ full_name: multiSelect ? '250px' : '250px',
814
+ site_name: '180px',
815
+ role_name: '150px',
816
+ manager_name: '180px',
817
+ email: '300px',
818
+ start_date: '150px',
819
+ employee_status: '120px',
820
+ total_hours_per_week: '100px',
821
+ term_time_only: '90px',
822
+ on_maternity_leave: '90px',
769
823
  };
770
-
824
+ const key = header.column.columnDef.accessorKey || header.column.id;
825
+ const width = defaultWidthByKey[key] || '120px';
771
826
  return (
772
- <th
773
- key={header.id}
774
- className="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0"
775
- style={{ width: columnWidths[index] || '120px', minWidth: columnWidths[index] || '120px' }}
776
- >
777
- {header.isPlaceholder
778
- ? null
779
- : flexRender(
780
- header.column.columnDef.header,
781
- header.getContext()
782
- )}
783
- </th>
827
+ <th
828
+ key={header.id}
829
+ className="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0"
830
+ style={{ width, minWidth: width }}
831
+ >
832
+ {header.isPlaceholder
833
+ ? null
834
+ : flexRender(
835
+ header.column.columnDef.header,
836
+ header.getContext()
837
+ )}
838
+ </th>
784
839
  );
785
840
  })}
786
841
  </tr>
@@ -1,5 +1,4 @@
1
1
  // Mock for import.meta.env to handle Vite environment variables in Jest tests
2
2
  export default {
3
- VITE_COMMON_API_FUNCTION_KEY: 'test-key',
4
- VITE_COMMON_API_BASE_URL: 'https://test-api.example.com',
5
- };
3
+ VITE_APIM_SCOPE: 'api://test-scope/.default',
4
+ };
@@ -32,6 +32,14 @@ export function DateRangePicker({
32
32
  const [internalRange, setInternalRange] = useState(selectedRange)
33
33
  const isSelectingRange = useRef(false)
34
34
 
35
+ const normalizeDate = (date) =>
36
+ date
37
+ ? new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
38
+ : null
39
+
40
+ const datesEqual = (a, b) =>
41
+ a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
42
+
35
43
  const handleOpenChange = (open) => {
36
44
  // If we're in the middle of selecting a range and trying to close, prevent it
37
45
  if (!open && isSelectingRange.current) {
@@ -59,90 +67,75 @@ export function DateRangePicker({
59
67
  setInternalRange(selectedRange)
60
68
  }, [selectedRange])
61
69
 
62
- const handleSelect = (range) => {
63
- console.log('🔍 handleSelect called with:', range)
64
- console.log('🔍 Current internalRange:', internalRange)
65
-
66
- // Normalize dates to avoid timezone issues
67
- const normalizedRange = range ? {
68
- from: range.from ? new Date(Date.UTC(range.from.getFullYear(), range.from.getMonth(), range.from.getDate())) : null,
69
- to: range.to ? new Date(Date.UTC(range.to.getFullYear(), range.to.getMonth(), range.to.getDate())) : null
70
- } : null
71
-
72
- console.log('🔍 Normalized range:', normalizedRange)
73
-
74
- // Implement cycling behavior: start date -> end date -> start date -> end date...
70
+ const handleSelect = (range, selectedDay) => {
71
+ if (!range || (!range.from && !range.to)) {
72
+ return
73
+ }
74
+
75
+ const normalizedRange = {
76
+ from: normalizeDate(range.from),
77
+ to: normalizeDate(range.to),
78
+ }
79
+ const normalizedSelectedDay = normalizeDate(selectedDay)
80
+
81
+ const hasCompleteCurrentRange = Boolean(
82
+ internalRange?.from && internalRange?.to
83
+ )
84
+
75
85
  let newRange = normalizedRange
76
-
77
- if (normalizedRange?.from && normalizedRange?.to) {
78
- // We have both dates from react-day-picker
79
- const currentFrom = internalRange?.from
80
- const currentTo = internalRange?.to
81
-
82
- console.log('🔍 Both dates provided - from:', normalizedRange.from, 'to:', normalizedRange.to)
83
- console.log('🔍 Current state - from:', currentFrom, 'to:', currentTo)
84
-
85
- if (currentFrom && currentTo) {
86
- // We have a complete range, so the next click should start a new cycle
87
- // The clicked date is the 'to' date, so use that as the new start date
88
- console.log('🔍 Complete range detected, starting new cycle')
89
- newRange = { from: normalizedRange.to, to: null }
90
- } else if (currentFrom && !currentTo) {
91
- // We have a start date but no end date, so this should complete the range
92
- console.log('🔍 Completing range with end date')
93
- newRange = { from: currentFrom, to: normalizedRange.to }
94
- } else {
95
- // No current selection, so this is the first click
96
- console.log('🔍 First click, setting start date')
97
- newRange = { from: normalizedRange.from, to: null }
86
+
87
+ if (normalizedRange.from && normalizedRange.to) {
88
+ if (hasCompleteCurrentRange && !isSelectingRange.current) {
89
+ const sameStart = datesEqual(normalizedRange.from, internalRange.from)
90
+
91
+ if (sameStart) {
92
+ // Adjusting end date on an existing range
93
+ newRange = {
94
+ from: internalRange.from,
95
+ to: normalizedRange.to,
96
+ }
97
+ } else {
98
+ // Starting a new range selection cycle
99
+ newRange = {
100
+ from: normalizedSelectedDay ?? normalizedRange.from,
101
+ to: null,
102
+ }
103
+ }
104
+ } else if (internalRange?.from && !internalRange?.to) {
105
+ // Completing an in-progress range
106
+ newRange = {
107
+ from: internalRange.from,
108
+ to: normalizedRange.to,
109
+ }
98
110
  }
99
- } else if (normalizedRange?.from && !normalizedRange?.to) {
100
- // We only have a from date (single date selection)
101
- const currentFrom = internalRange?.from
102
- const currentTo = internalRange?.to
103
-
104
- console.log('🔍 Only from date provided:', normalizedRange.from)
105
- console.log('🔍 Current state - from:', currentFrom, 'to:', currentTo)
106
-
107
- if (currentFrom && currentTo) {
108
- // We have a complete range, so the next click should start a new cycle
109
- console.log('🔍 Complete range detected, starting new cycle (single date)')
110
- newRange = { from: normalizedRange.from, to: null }
111
- } else if (currentFrom && !currentTo) {
112
- // We have a start date but no end date, so this should complete the range
113
- console.log('🔍 Completing range with end date (single date)')
114
- newRange = { from: currentFrom, to: normalizedRange.from }
111
+ } else if (normalizedRange.from && !normalizedRange.to) {
112
+ if (hasCompleteCurrentRange && !isSelectingRange.current) {
113
+ // New cycle starting from the selected day
114
+ newRange = {
115
+ from: normalizedSelectedDay ?? normalizedRange.from,
116
+ to: null,
117
+ }
115
118
  } else {
116
- // No current selection, so this is the first click
117
- console.log('🔍 First click, setting start date (single date)')
118
- newRange = { from: normalizedRange.from, to: null }
119
+ newRange = {
120
+ from: normalizedRange.from,
121
+ to: null,
122
+ }
119
123
  }
124
+ } else {
125
+ newRange = null
120
126
  }
121
-
122
- console.log('🔍 Final newRange:', newRange)
123
-
124
- // Update internal state immediately for UI responsiveness
127
+
125
128
  setInternalRange(newRange)
126
-
127
- // Update the selecting range flag
129
+
128
130
  if (newRange?.from && !newRange?.to) {
129
131
  isSelectingRange.current = true
130
- } else if (newRange?.from && newRange?.to) {
131
- isSelectingRange.current = false
132
132
  } else {
133
133
  isSelectingRange.current = false
134
134
  }
135
-
136
- // Only call onSelect when both dates are selected
137
- if (newRange?.from && newRange?.to) {
138
- onSelect(newRange)
139
- // Don't close the popover automatically - let user click outside to close
140
- } else if (!newRange?.from && !newRange?.to) {
141
- // Range cleared - call onSelect immediately
135
+
136
+ if (!newRange?.from || newRange?.to) {
142
137
  onSelect(newRange)
143
138
  }
144
- // Don't call onSelect when only first date is selected
145
- // Popover stays open until both dates are selected or user clicks outside
146
139
  }
147
140
 
148
141
  return (
@@ -200,10 +193,9 @@ export function DateRangePicker({
200
193
  mode="range"
201
194
  defaultMonth={internalRange?.from}
202
195
  selected={internalRange}
203
- onSelect={(range) => {
204
- // Only handle the selection if we have a valid range
196
+ onSelect={(range, selectedDay) => {
205
197
  if (range && (range.from || range.to)) {
206
- handleSelect(range)
198
+ handleSelect(range, selectedDay)
207
199
  }
208
200
  }}
209
201
  numberOfMonths={numberOfMonths}
@@ -289,7 +281,7 @@ export function DatePicker({
289
281
  }
290
282
 
291
283
  return (
292
- <Popover open={isOpen} onOpenChange={setIsOpen}>
284
+ <Popover open={isOpen} onOpenChange={setIsOpen} modal={false}>
293
285
  <PopoverTrigger asChild>
294
286
  <Button
295
287
  variant="outline"
@@ -320,4 +312,4 @@ export function DatePicker({
320
312
  </PopoverContent>
321
313
  </Popover>
322
314
  )
323
- }
315
+ }
@@ -29,7 +29,7 @@ function PopoverContent({
29
29
  align={align}
30
30
  sideOffset={sideOffset}
31
31
  className={cn(
32
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
32
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[60] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
33
33
  className
34
34
  )}
35
35
  {...props} />