@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 +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 +137 -87
- 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
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
|
|
191
|
+
Set this environment variable in your application:
|
|
192
192
|
|
|
193
193
|
```env
|
|
194
|
-
|
|
194
|
+
VITE_APIM_SCOPE=api://your-apim-app-id/.default
|
|
195
195
|
```
|
|
196
196
|
|
|
197
197
|
## Documentation
|
|
198
198
|
|
|
199
|
-
- [ChildSearchModal Documentation](
|
|
200
|
-
- [ChildSearchModal README](
|
|
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
package/src/ChildSearchModal.jsx
CHANGED
|
@@ -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
|
-
//
|
|
141
|
-
const
|
|
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
|
-
|
|
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
|
|
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:
|
|
360
|
-
height:
|
|
389
|
+
maxHeight: `${maxHeightVh}vh`,
|
|
390
|
+
height: `${maxHeightVh}vh`,
|
|
361
391
|
display: 'flex',
|
|
362
392
|
flexDirection: 'column',
|
|
363
393
|
position: 'relative'
|
package/src/ChildSearchPage.jsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
const
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
),
|
|
174
|
+
),
|
|
190
175
|
}),
|
|
191
|
-
// Site column - sortable
|
|
192
176
|
columnHelper.accessor("site_name", {
|
|
193
|
-
header: createSortableHeader("site_name", "Site"),
|
|
194
|
-
cell: ({ row }) =>
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
statusColors[status] ||
|
|
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 ||
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
161
|
-
const
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
),
|
|
179
|
+
),
|
|
198
180
|
}),
|
|
199
|
-
// Site column - sortable
|
|
200
181
|
columnHelper.accessor("site_name", {
|
|
201
|
-
header: createSortableHeader("site_name", "Site"),
|
|
202
|
-
cell: ({ row }) =>
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
756
|
-
//
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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>
|
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const normalizedRange =
|
|
68
|
-
from:
|
|
69
|
-
to:
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
} else {
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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-
|
|
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} />
|