@visns-studio/visns-components 5.3.3 → 5.3.6

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 CHANGED
@@ -14,7 +14,7 @@
14
14
  "akar-icons": "^1.9.31",
15
15
  "array-move": "^4.0.0",
16
16
  "awesome-debounce-promise": "^2.1.0",
17
- "axios": "^1.8.1",
17
+ "axios": "^1.8.3",
18
18
  "dayjs": "^1.11.13",
19
19
  "fabric": "^6.5.4",
20
20
  "file-saver": "^2.0.5",
@@ -78,7 +78,7 @@
78
78
  "react-dom": "^17.0.0 || ^18.0.0"
79
79
  },
80
80
  "name": "@visns-studio/visns-components",
81
- "version": "5.3.3",
81
+ "version": "5.3.6",
82
82
  "description": "Various packages to assist in the development of our Custom Applications.",
83
83
  "main": "src/index.js",
84
84
  "files": [
@@ -112,10 +112,14 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
112
112
  }
113
113
 
114
114
  if (widget.api?.url && widget.api?.method) {
115
+ const requestParams = {
116
+ ...widgetFilters,
117
+ ...(widget.api.params || {}),
118
+ };
115
119
  return CustomFetch(
116
120
  widget.api.url,
117
121
  widget.api.method,
118
- widgetFilters
122
+ requestParams
119
123
  );
120
124
  }
121
125
  return null;
@@ -515,9 +519,64 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
515
519
  <ResponsivePie data={widgetData} {...widget.props} />
516
520
  );
517
521
  case 'bar': {
518
- if (widgetData.keys) {
519
- widget.props.keys = widgetData.keys;
522
+ // Handle both data structures
523
+ let barData = [];
524
+ let colors = {};
525
+ let keys = [];
526
+
527
+ // Handle new data structure (from your first example)
528
+ if (
529
+ widgetData.data &&
530
+ Array.isArray(widgetData.data) &&
531
+ widgetData.colors
532
+ ) {
533
+ barData = widgetData.data;
534
+ colors = widgetData.colors;
535
+ // Extract keys from the first data item, excluding 'month' or any other index field
536
+ keys = Object.keys(barData[0] || {}).filter(
537
+ (key) => key !== 'month'
538
+ );
520
539
  }
540
+ // Handle newer data structure (from your second example)
541
+ else if (
542
+ widgetData.data &&
543
+ Array.isArray(widgetData.data) &&
544
+ widgetData.keys
545
+ ) {
546
+ barData = widgetData.data;
547
+ keys = widgetData.keys;
548
+ // Extract colors from the first data item
549
+ if (barData[0]) {
550
+ keys.forEach((key) => {
551
+ const colorKey = `${key}Color`;
552
+ if (barData[0][colorKey]) {
553
+ colors[key] = barData[0][colorKey];
554
+ }
555
+ });
556
+ }
557
+ }
558
+ // Handle legacy data structure
559
+ else {
560
+ barData =
561
+ widgetData.bar?.data ||
562
+ widgetData.data ||
563
+ widgetData;
564
+ if (widgetData.keys) {
565
+ keys = widgetData.keys;
566
+ }
567
+ }
568
+
569
+ // Set up colors if they exist
570
+ if (Object.keys(colors).length > 0) {
571
+ widget.props.colors = ({ id }) => colors[id];
572
+ }
573
+
574
+ // Set keys in props if they exist
575
+ if (keys.length > 0) {
576
+ widget.props.keys = keys;
577
+ }
578
+
579
+ // Handle legend setup
521
580
  let legendMapping = {};
522
581
  if (widget.legend) {
523
582
  legendMapping = widget.legend.reduce((acc, curr) => {
@@ -544,14 +603,14 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
544
603
  };
545
604
  widget.props.tooltip = CustomTooltip;
546
605
  }
547
- const barData =
548
- widgetData.bar?.data || widgetData.data || widgetData;
606
+
549
607
  if (barData?.length > 0) {
550
608
  return (
551
609
  <div style={{ height: widget.height || '600px' }}>
552
610
  <ResponsiveBar
553
611
  key={`${widget.id}-${widget.size}`}
554
612
  data={barData}
613
+ indexBy="month"
555
614
  {...widget.props}
556
615
  />
557
616
  </div>
@@ -12,24 +12,23 @@ import MultiSelect from '../MultiSelect';
12
12
  import 'react-confirm-alert/src/react-confirm-alert.css';
13
13
  import styles from './styles/GenericEditableTable.module.scss';
14
14
 
15
- // Updated ColourPicker with an expanded pastel palette and a refined Akar icon
15
+ // Updated ColourPicker with an expanded pastel palette
16
16
  const ColourPicker = ({ value, columnId, keyCounter, onChange }) => {
17
17
  const [colorPickerShow, setColorPickerShow] = useState(false);
18
18
  const buttonRef = useRef(null);
19
19
  const popoverRef = useRef(null);
20
20
 
21
- // Expanded custom palette of pastel colours
22
21
  const pastelColors = [
23
- '#FFB3BA', // pastel red
24
- '#FFDFBA', // pastel orange
25
- '#FFFFBA', // pastel yellow
26
- '#BAFFC9', // pastel green
27
- '#BAE1FF', // pastel blue
28
- '#BFFCC6', // additional pastel green
29
- '#E2BAFF', // pastel purple
30
- '#C3B1E1', // pastel lavender
31
- '#F0E6EF', // pastel pink
32
- '#D0E8F2', // pastel light blue
22
+ '#FFB3BA',
23
+ '#FFDFBA',
24
+ '#FFFFBA',
25
+ '#BAFFC9',
26
+ '#BAE1FF',
27
+ '#BFFCC6',
28
+ '#E2BAFF',
29
+ '#C3B1E1',
30
+ '#F0E6EF',
31
+ '#D0E8F2',
33
32
  ];
34
33
 
35
34
  useEffect(() => {
@@ -43,7 +42,6 @@ const ColourPicker = ({ value, columnId, keyCounter, onChange }) => {
43
42
  setColorPickerShow(false);
44
43
  }
45
44
  };
46
-
47
45
  document.addEventListener('mousedown', handleClickOutside);
48
46
  return () =>
49
47
  document.removeEventListener('mousedown', handleClickOutside);
@@ -54,7 +52,6 @@ const ColourPicker = ({ value, columnId, keyCounter, onChange }) => {
54
52
  : {};
55
53
 
56
54
  const handleClear = () => {
57
- // Passing an empty string to represent "no colour selected"
58
55
  onChange('', columnId, keyCounter);
59
56
  setColorPickerShow(false);
60
57
  };
@@ -102,7 +99,6 @@ const ColourPicker = ({ value, columnId, keyCounter, onChange }) => {
102
99
  setColorPickerShow(false);
103
100
  }}
104
101
  />
105
- {/* Extra clear button added below the CompactPicker */}
106
102
  <div className={styles.clearContainer}>
107
103
  <button
108
104
  onClick={handleClear}
@@ -122,27 +118,94 @@ const ColourPicker = ({ value, columnId, keyCounter, onChange }) => {
122
118
 
123
119
  const GenericEditableTable = ({
124
120
  schedulingConfig,
125
- data,
121
+ data, // incoming data – may be a plain object or already an array of datasets
126
122
  dataId,
127
123
  preloadedOptions,
128
- setData,
124
+ setData, // callback to update the parent state
129
125
  }) => {
126
+ const isInitialLoad = useRef(true);
130
127
  const { columns, rows, form, title } = schedulingConfig;
131
128
  const hasCategory = rows && rows.categoryKey; // Only group if defined
132
129
 
130
+ // dataSets: each item => { created_at: string, data: array of rows }
131
+ const [dataSets, setDataSets] = useState([]);
132
+ // Index of the currently active dataset
133
+ const [activeDatasetIndex, setActiveDatasetIndex] = useState(0);
134
+ // Local copy of the rows for the active dataset
133
135
  const [localData, setLocalData] = useState([]);
134
- // For new (unsaved) entries: if grouped, store per group; if not, a single object.
136
+ // For new (unsaved) entries in the active dataset
135
137
  const [newEntries, setNewEntries] = useState({});
136
138
  const [newEntry, setNewEntry] = useState({});
137
139
 
138
- // Determine if a total column exists and its index.
139
- const totalColumnIndex = columns.findIndex((col) => col.type === 'total');
140
+ // On mount or when 'data' changes, read from data[schedulingConfig.rows.key] and handle both new & old formats
141
+ useEffect(() => {
142
+ const raw = data && data[schedulingConfig.rows.key];
143
+ if (!raw) {
144
+ setDataSets([]);
145
+ setActiveDatasetIndex(0);
146
+ isInitialLoad.current = false;
147
+ return;
148
+ }
149
+
150
+ let newDataSets;
151
+ if (Array.isArray(raw)) {
152
+ if (
153
+ raw.length > 0 &&
154
+ typeof raw[0].created_at === 'string' &&
155
+ Array.isArray(raw[0].data)
156
+ ) {
157
+ newDataSets = raw;
158
+ } else {
159
+ newDataSets = [
160
+ {
161
+ created_at: new Date().toISOString(),
162
+ data: raw,
163
+ },
164
+ ];
165
+ }
166
+ } else {
167
+ newDataSets = [
168
+ {
169
+ created_at: new Date().toISOString(),
170
+ data: raw,
171
+ },
172
+ ];
173
+ }
174
+ setDataSets(newDataSets);
175
+
176
+ if (isInitialLoad.current) {
177
+ // On initial load, always select the latest dataset
178
+ setActiveDatasetIndex(newDataSets.length - 1);
179
+ isInitialLoad.current = false;
180
+ } else {
181
+ // Otherwise, if the current index is valid, keep it; else, select the last
182
+ setActiveDatasetIndex((prevIndex) =>
183
+ prevIndex < newDataSets.length
184
+ ? prevIndex
185
+ : newDataSets.length - 1
186
+ );
187
+ }
188
+ }, [data, schedulingConfig.rows.key]);
189
+
190
+ // Whenever activeDatasetIndex changes, sync localData from the chosen dataset
191
+ useEffect(() => {
192
+ const activeDataSet = dataSets[activeDatasetIndex];
193
+ if (activeDataSet && Array.isArray(activeDataSet.data)) {
194
+ const enrichedData = activeDataSet.data.map((entry, index) => ({
195
+ ...entry,
196
+ sort_order: entry.sort_order ?? index + 1,
197
+ }));
198
+ setLocalData(sortBySortOrder(enrichedData));
199
+ } else {
200
+ setLocalData([]);
201
+ }
202
+ }, [dataSets, activeDatasetIndex]);
140
203
 
141
- // Helper to sort by sort_order without mutating the original array
204
+ // Helper to sort by sort_order
142
205
  const sortBySortOrder = (dataArray) =>
143
206
  [...dataArray].sort((a, b) => a.sort_order - b.sort_order);
144
207
 
145
- // Helper: if there is a column of type 'colour', return that value from the entry.
208
+ // Return background color if there's a column of type 'colour'
146
209
  const getRowBackground = (entry) => {
147
210
  const colourColumn = columns.find((col) => col.type === 'colour');
148
211
  return colourColumn && entry[colourColumn.id]
@@ -150,7 +213,7 @@ const GenericEditableTable = ({
150
213
  : undefined;
151
214
  };
152
215
 
153
- // New helper to calculate total using the updated keys structure
216
+ // Calculate total for columns of type 'total'
154
217
  const calculateTotal = (entry, keys) => {
155
218
  let total = 0;
156
219
  if (Array.isArray(keys)) {
@@ -164,8 +227,7 @@ const GenericEditableTable = ({
164
227
  keys.price &&
165
228
  keys.unit
166
229
  ) {
167
- const priceKeys = keys.price;
168
- const unitKeys = keys.unit;
230
+ const { price: priceKeys, unit: unitKeys } = keys;
169
231
  if (unitKeys.length === 1) {
170
232
  const unitValue = parseFloat(entry[unitKeys[0]]) || 0;
171
233
  total = priceKeys.reduce(
@@ -186,24 +248,19 @@ const GenericEditableTable = ({
186
248
  return total;
187
249
  };
188
250
 
189
- // On load, enrich the data and sort by sort_order
190
- useEffect(() => {
191
- const enrichedData = data[rows.key]?.map((entry, index) => ({
192
- ...entry,
193
- sort_order: entry.sort_order ?? index + 1,
194
- }));
195
- const sortedData = enrichedData ? sortBySortOrder(enrichedData) : [];
196
- setLocalData(sortedData);
197
- }, [data, rows.key]);
198
-
251
+ // Debounced update to save entire dataSets array
199
252
  const debouncedUpdate = useMemo(
200
253
  () =>
201
- debounce(async (updatedData) => {
254
+ debounce(async (updatedDataSets) => {
202
255
  try {
256
+ // Pass the array of datasets under schedulingConfig.rows.key
257
+ const payload = {
258
+ [schedulingConfig.rows.key]: updatedDataSets,
259
+ };
203
260
  const res = await CustomFetch(
204
261
  `${form.url}/${dataId}`,
205
262
  'PUT',
206
- updatedData
263
+ payload
207
264
  );
208
265
  if (!res.error) {
209
266
  if (res.data?.data) {
@@ -216,10 +273,168 @@ const GenericEditableTable = ({
216
273
  console.error('Error updating field:', error);
217
274
  }
218
275
  }, 1000),
219
- [form.url, dataId, setData]
276
+ [form.url, dataId, setData, schedulingConfig.rows.key]
220
277
  );
221
278
 
222
- // Group data by category (if defined) and sort each group by sort_order
279
+ // 1) Handle field change for existing rows
280
+ const handleFieldChange = (value, fieldId, keyCounter) => {
281
+ setLocalData((prevLocalData) => {
282
+ const updatedLocalData = [...prevLocalData];
283
+ updatedLocalData[keyCounter] = {
284
+ ...updatedLocalData[keyCounter],
285
+ [fieldId]: value,
286
+ };
287
+
288
+ // If there's date arithmetic logic
289
+ columns.forEach((col) => {
290
+ if (
291
+ col.add &&
292
+ (fieldId === col.id || fieldId === col.add.from)
293
+ ) {
294
+ const fromVal = updatedLocalData[keyCounter][col.add.from];
295
+ const daysVal = parseFloat(
296
+ updatedLocalData[keyCounter][col.id]
297
+ );
298
+ if (fromVal && !isNaN(daysVal)) {
299
+ const newDate = new Date(fromVal);
300
+ if (!isNaN(newDate)) {
301
+ newDate.setDate(newDate.getDate() + daysVal);
302
+ const yyyy = newDate.getFullYear();
303
+ let mm = newDate.getMonth() + 1;
304
+ let dd = newDate.getDate();
305
+ if (mm < 10) mm = '0' + mm;
306
+ if (dd < 10) dd = '0' + dd;
307
+ updatedLocalData[keyCounter][
308
+ col.add.to
309
+ ] = `${yyyy}-${mm}-${dd}`;
310
+ }
311
+ }
312
+ }
313
+ });
314
+
315
+ // Update the active dataset
316
+ const updatedDataSets = [...dataSets];
317
+ updatedDataSets[activeDatasetIndex] = {
318
+ ...updatedDataSets[activeDatasetIndex],
319
+ data: updatedLocalData,
320
+ };
321
+ setDataSets(updatedDataSets);
322
+
323
+ // Save
324
+ debouncedUpdate(updatedDataSets);
325
+ return updatedLocalData;
326
+ });
327
+ };
328
+
329
+ // 2) Handle field change for *new* rows (before they're officially added)
330
+ const handleNewFieldChange = (value, fieldId, groupId = null) => {
331
+ if (hasCategory) {
332
+ setNewEntries((prev) => {
333
+ const updated = {
334
+ ...prev,
335
+ [groupId]: { ...prev[groupId], [fieldId]: value },
336
+ };
337
+ columns.forEach((col) => {
338
+ if (
339
+ col.add &&
340
+ (fieldId === col.id || fieldId === col.add.from)
341
+ ) {
342
+ const fromVal = updated[groupId][col.add.from];
343
+ const daysVal = parseFloat(updated[groupId][col.id]);
344
+ if (fromVal && !isNaN(daysVal)) {
345
+ const newDate = new Date(fromVal);
346
+ if (!isNaN(newDate)) {
347
+ newDate.setDate(newDate.getDate() + daysVal);
348
+ const yyyy = newDate.getFullYear();
349
+ let mm = newDate.getMonth() + 1;
350
+ let dd = newDate.getDate();
351
+ if (mm < 10) mm = '0' + mm;
352
+ if (dd < 10) dd = '0' + dd;
353
+ updated[groupId][
354
+ col.add.to
355
+ ] = `${yyyy}-${mm}-${dd}`;
356
+ }
357
+ }
358
+ }
359
+ });
360
+ return updated;
361
+ });
362
+ } else {
363
+ setNewEntry((prev) => {
364
+ const updated = { ...prev, [fieldId]: value };
365
+ columns.forEach((col) => {
366
+ if (
367
+ col.add &&
368
+ (fieldId === col.id || fieldId === col.add.from)
369
+ ) {
370
+ const fromVal = updated[col.add.from];
371
+ const daysVal = parseFloat(updated[col.id]);
372
+ if (fromVal && !isNaN(daysVal)) {
373
+ const newDate = new Date(fromVal);
374
+ if (!isNaN(newDate)) {
375
+ newDate.setDate(newDate.getDate() + daysVal);
376
+ const yyyy = newDate.getFullYear();
377
+ let mm = newDate.getMonth() + 1;
378
+ let dd = newDate.getDate();
379
+ if (mm < 10) mm = '0' + mm;
380
+ if (dd < 10) dd = '0' + dd;
381
+ updated[col.add.to] = `${yyyy}-${mm}-${dd}`;
382
+ }
383
+ }
384
+ }
385
+ });
386
+ return updated;
387
+ });
388
+ }
389
+ };
390
+
391
+ // 3) Adding a new row to the active dataset
392
+ const handleAddNewEntry = (groupId = null) => {
393
+ const newRowData = hasCategory ? newEntries[groupId] || {} : newEntry;
394
+ if (!newRowData || Object.keys(newRowData).length === 0) {
395
+ toast.error('Please fill in some data before adding.');
396
+ return;
397
+ }
398
+ const maxSortOrder = localData.reduce(
399
+ (max, row) => Math.max(max, row.sort_order || 0),
400
+ 0
401
+ );
402
+ const newRow = {
403
+ ...newRowData,
404
+ id: `new-${Date.now()}`,
405
+ sort_order: maxSortOrder + 1,
406
+ };
407
+
408
+ // If there's a category column, fill it in
409
+ if (hasCategory && groupId !== 'no_category') {
410
+ newRow[rows.categoryKey.id] = {
411
+ id: groupId,
412
+ [rows.categoryKey.label]: groupId,
413
+ };
414
+ }
415
+
416
+ const updatedLocalData = [...localData, newRow];
417
+ setLocalData(updatedLocalData);
418
+
419
+ // Update the active dataset
420
+ const updatedDataSets = [...dataSets];
421
+ updatedDataSets[activeDatasetIndex] = {
422
+ ...updatedDataSets[activeDatasetIndex],
423
+ data: updatedLocalData,
424
+ };
425
+ setDataSets(updatedDataSets);
426
+ debouncedUpdate(updatedDataSets);
427
+
428
+ // Reset new-entry state
429
+ if (hasCategory) {
430
+ setNewEntries((prev) => ({ ...prev, [groupId]: {} }));
431
+ } else {
432
+ setNewEntry({});
433
+ }
434
+ toast.success('Row added');
435
+ };
436
+
437
+ // 4) Group data by category (if defined)
223
438
  const groupCategoriesByType = (dataArray) => {
224
439
  if (!rows.categoryKey) return [];
225
440
  const grouped = {};
@@ -231,8 +446,6 @@ const GenericEditableTable = ({
231
446
  : null;
232
447
  const categoryId = categoryInfo ? categoryInfo.id : null;
233
448
  const groupKey = categoryId || 'no_category';
234
- // Use the category's sort_order if provided; otherwise, default to a high number (e.g. 9999)
235
-
236
449
  const catSortOrder =
237
450
  categoryInfo && typeof categoryInfo.sort_order !== 'undefined'
238
451
  ? categoryInfo.sort_order
@@ -244,25 +457,21 @@ const GenericEditableTable = ({
244
457
  category:
245
458
  categoryLabel ||
246
459
  (groupKey === 'no_category' ? 'No Category' : ''),
247
- catSortOrder, // assign the sort order (or fallback)
460
+ catSortOrder,
248
461
  entries: [],
249
462
  };
250
463
  }
251
- grouped[groupKey].entries.push({
252
- ...entry,
253
- keyCounter: index,
254
- });
464
+ grouped[groupKey].entries.push({ ...entry, keyCounter: index });
255
465
  });
256
466
 
257
- // Sort entries within each group by their universal sort_order
467
+ // Sort entries within each group
258
468
  Object.values(grouped).forEach((group) => {
259
469
  group.entries.sort((a, b) => a.sort_order - b.sort_order);
260
470
  });
261
471
 
262
- // Now sort the groups by the cost category's sort_order (catSortOrder)
472
+ // Sort groups by catSortOrder
263
473
  const groupedArray = Object.values(grouped);
264
474
  groupedArray.sort((a, b) => {
265
- // Optionally, place 'no_category' first (or adjust as needed)
266
475
  if (a.id === 'no_category') return -1;
267
476
  if (b.id === 'no_category') return 1;
268
477
  return a.catSortOrder - b.catSortOrder;
@@ -272,21 +481,14 @@ const GenericEditableTable = ({
272
481
  };
273
482
 
274
483
  const groupedCategories = useMemo(() => {
275
- if (hasCategory) {
276
- return groupCategoriesByType(localData);
277
- }
278
- return [];
484
+ return hasCategory ? groupCategoriesByType(localData) : [];
279
485
  }, [localData, hasCategory]);
280
486
 
281
- // Copy all values from a column to the clipboard
487
+ // 5) Column copy
282
488
  const handleColumnCopy = (column, group = null) => {
283
- let values;
284
-
285
- if (group) {
286
- values = group.entries.map((entry) => entry[column.id]);
287
- } else {
288
- values = localData.map((entry) => entry[column.id]);
289
- }
489
+ let values = group
490
+ ? group.entries.map((entry) => entry[column.id])
491
+ : localData.map((entry) => entry[column.id]);
290
492
 
291
493
  const textToCopy = values
292
494
  .filter((val) => val !== undefined && val !== null)
@@ -306,49 +508,7 @@ const GenericEditableTable = ({
306
508
  .catch(() => toast.error('Failed to copy to clipboard'));
307
509
  };
308
510
 
309
- // Updated handleFieldChange to support "add" functionality
310
- const handleFieldChange = (value, fieldId, keyCounter) => {
311
- setLocalData((prev) => {
312
- const updatedDetail = [...prev];
313
- updatedDetail[keyCounter] = {
314
- ...updatedDetail[keyCounter],
315
- [fieldId]: value,
316
- };
317
-
318
- // Loop through columns to see if any has an "add" property.
319
- columns.forEach((col) => {
320
- if (
321
- col.add &&
322
- (fieldId === col.id || fieldId === col.add.from)
323
- ) {
324
- const fromVal = updatedDetail[keyCounter][col.add.from];
325
- const daysVal = parseFloat(
326
- updatedDetail[keyCounter][col.id]
327
- );
328
- if (fromVal && !isNaN(daysVal)) {
329
- const newDate = new Date(fromVal);
330
- if (!isNaN(newDate)) {
331
- newDate.setDate(newDate.getDate() + daysVal);
332
- // Format the date as YYYY-MM-DD
333
- const yyyy = newDate.getFullYear();
334
- let mm = newDate.getMonth() + 1;
335
- let dd = newDate.getDate();
336
- if (mm < 10) mm = '0' + mm;
337
- if (dd < 10) dd = '0' + dd;
338
- updatedDetail[keyCounter][
339
- col.add.to
340
- ] = `${yyyy}-${mm}-${dd}`;
341
- }
342
- }
343
- }
344
- });
345
-
346
- debouncedUpdate({ [rows.key]: updatedDetail });
347
- return updatedDetail;
348
- });
349
- };
350
-
351
- // For grouped data, sort handler remains unchanged
511
+ // 6) Sorting: grouped
352
512
  const handleSortChangeGrouped = (groupId, entryId, direction) => {
353
513
  if (!hasCategory) return;
354
514
  setLocalData((prev) => {
@@ -356,12 +516,10 @@ const GenericEditableTable = ({
356
516
  const group = groupCategoriesByType(updatedDetail).find(
357
517
  (grp) => grp.id === groupId
358
518
  );
359
-
360
519
  if (group) {
361
520
  const index = group.entries.findIndex(
362
521
  (entry) => entry.id === entryId
363
522
  );
364
-
365
523
  if (
366
524
  (direction === 'up' && index > 0) ||
367
525
  (direction === 'down' && index < group.entries.length - 1)
@@ -376,6 +534,7 @@ const GenericEditableTable = ({
376
534
  group.entries[index].sort_order,
377
535
  ];
378
536
 
537
+ // Rebuild array with swapped sort_order
379
538
  const reordered = updatedDetail.map((item) => {
380
539
  const found = group.entries.find(
381
540
  (e) => e.id === item.id
@@ -385,7 +544,15 @@ const GenericEditableTable = ({
385
544
  : item;
386
545
  });
387
546
 
388
- debouncedUpdate({ [rows.key]: reordered });
547
+ // Update the active dataset
548
+ const updatedDataSets = [...dataSets];
549
+ updatedDataSets[activeDatasetIndex] = {
550
+ ...updatedDataSets[activeDatasetIndex],
551
+ data: reordered,
552
+ };
553
+ setDataSets(updatedDataSets);
554
+ debouncedUpdate(updatedDataSets);
555
+
389
556
  return reordered;
390
557
  }
391
558
  }
@@ -393,7 +560,7 @@ const GenericEditableTable = ({
393
560
  });
394
561
  };
395
562
 
396
- // For ungrouped data, sort before swapping items.
563
+ // 7) Sorting: ungrouped
397
564
  const handleSortChangeUngrouped = (entryId, direction) => {
398
565
  setLocalData((prev) => {
399
566
  const sortedData = sortBySortOrder(prev);
@@ -405,18 +572,26 @@ const GenericEditableTable = ({
405
572
  ) {
406
573
  return prev;
407
574
  }
408
-
409
575
  const swapIndex = direction === 'up' ? index - 1 : index + 1;
410
576
  [sortedData[index].sort_order, sortedData[swapIndex].sort_order] = [
411
577
  sortedData[swapIndex].sort_order,
412
578
  sortedData[index].sort_order,
413
579
  ];
414
580
 
415
- debouncedUpdate({ [rows.key]: sortedData });
581
+ // Update the active dataset
582
+ const updatedDataSets = [...dataSets];
583
+ updatedDataSets[activeDatasetIndex] = {
584
+ ...updatedDataSets[activeDatasetIndex],
585
+ data: sortedData,
586
+ };
587
+ setDataSets(updatedDataSets);
588
+ debouncedUpdate(updatedDataSets);
589
+
416
590
  return sortedData;
417
591
  });
418
592
  };
419
593
 
594
+ // 8) Deletion
420
595
  const handleDeleteRow = (entry) => {
421
596
  confirmAlert({
422
597
  title: 'Confirm to Delete',
@@ -429,19 +604,23 @@ const GenericEditableTable = ({
429
604
  const updatedDetail = prev.filter(
430
605
  (item) => item.id !== entry.id
431
606
  );
432
- debouncedUpdate({ [rows.key]: updatedDetail });
607
+ const updatedDataSets = [...dataSets];
608
+ updatedDataSets[activeDatasetIndex] = {
609
+ ...updatedDataSets[activeDatasetIndex],
610
+ data: updatedDetail,
611
+ };
612
+ setDataSets(updatedDataSets);
613
+ debouncedUpdate(updatedDataSets);
433
614
  return updatedDetail;
434
615
  });
435
616
  },
436
617
  },
437
- {
438
- label: 'No',
439
- },
618
+ { label: 'No' },
440
619
  ],
441
620
  });
442
621
  };
443
622
 
444
- // Render existing row field (same as before)
623
+ // RENDERING HELPERS
445
624
  const renderScheduledTableField = (entry, column, keyCounter) => {
446
625
  if (!entry || !column) return null;
447
626
 
@@ -538,8 +717,8 @@ const GenericEditableTable = ({
538
717
  settings={{ id: column.id, compact: true }}
539
718
  className={styles.selectFullWidth}
540
719
  multi={false}
541
- onChange={(value) =>
542
- handleFieldChange(value, column.id, keyCounter)
720
+ onChange={(val) =>
721
+ handleFieldChange(val, column.id, keyCounter)
543
722
  }
544
723
  inputValue={entry[column.id] || ''}
545
724
  options={multiOptions}
@@ -571,117 +750,7 @@ const GenericEditableTable = ({
571
750
  }
572
751
  };
573
752
 
574
- // --- New row functions for inline creation ---
575
-
576
- // Updated handleNewFieldChange to support "add" functionality in new rows
577
- const handleNewFieldChange = (value, fieldId, groupId = null) => {
578
- if (hasCategory) {
579
- setNewEntries((prev) => {
580
- const updated = {
581
- ...prev,
582
- [groupId]: {
583
- ...prev[groupId],
584
- [fieldId]: value,
585
- },
586
- };
587
- columns.forEach((col) => {
588
- if (
589
- col.add &&
590
- (fieldId === col.id || fieldId === col.add.from)
591
- ) {
592
- const fromVal = updated[groupId][col.add.from];
593
- const daysVal = parseFloat(updated[groupId][col.id]);
594
- if (fromVal && !isNaN(daysVal)) {
595
- const newDate = new Date(fromVal);
596
- if (!isNaN(newDate)) {
597
- newDate.setDate(newDate.getDate() + daysVal);
598
- const yyyy = newDate.getFullYear();
599
- let mm = newDate.getMonth() + 1;
600
- let dd = newDate.getDate();
601
- if (mm < 10) mm = '0' + mm;
602
- if (dd < 10) dd = '0' + dd;
603
- updated[groupId][
604
- col.add.to
605
- ] = `${yyyy}-${mm}-${dd}`;
606
- }
607
- }
608
- }
609
- });
610
- return updated;
611
- });
612
- } else {
613
- setNewEntry((prev) => {
614
- const updated = {
615
- ...prev,
616
- [fieldId]: value,
617
- };
618
- columns.forEach((col) => {
619
- if (
620
- col.add &&
621
- (fieldId === col.id || fieldId === col.add.from)
622
- ) {
623
- const fromVal = updated[col.add.from];
624
- const daysVal = parseFloat(updated[col.id]);
625
- if (fromVal && !isNaN(daysVal)) {
626
- const newDate = new Date(fromVal);
627
- if (!isNaN(newDate)) {
628
- newDate.setDate(newDate.getDate() + daysVal);
629
- const yyyy = newDate.getFullYear();
630
- let mm = newDate.getMonth() + 1;
631
- let dd = newDate.getDate();
632
- if (mm < 10) mm = '0' + mm;
633
- if (dd < 10) dd = '0' + dd;
634
- updated[col.add.to] = `${yyyy}-${mm}-${dd}`;
635
- }
636
- }
637
- }
638
- });
639
- return updated;
640
- });
641
- }
642
- };
643
-
644
- // Add the new row to localData and update the backend
645
- const handleAddNewEntry = (groupId = null) => {
646
- const newRowData = hasCategory ? newEntries[groupId] || {} : newEntry;
647
- if (!newRowData || Object.keys(newRowData).length === 0) {
648
- toast.error('Please fill in some data before adding.');
649
- return;
650
- }
651
- // Determine a new sort_order
652
- const maxSortOrder = localData.reduce(
653
- (max, row) => Math.max(max, row.sort_order || 0),
654
- 0
655
- );
656
- const newRow = {
657
- ...newRowData,
658
- id: `new-${Date.now()}`, // temporary ID; adjust as needed
659
- sort_order: maxSortOrder + 1,
660
- };
661
-
662
- // For grouped data, prepopulate the category field if needed
663
- if (hasCategory && groupId !== 'no_category') {
664
- newRow[rows.categoryKey.id] = {
665
- id: groupId,
666
- [rows.categoryKey.label]: groupId,
667
- };
668
- }
669
-
670
- setLocalData((prev) => {
671
- const updated = [...prev, newRow];
672
- debouncedUpdate({ [rows.key]: updated });
673
- return updated;
674
- });
675
- // Reset the new entry state for the corresponding group or overall
676
- if (hasCategory) {
677
- setNewEntries((prev) => ({ ...prev, [groupId]: {} }));
678
- } else {
679
- setNewEntry({});
680
- }
681
- toast.success('Row added');
682
- };
683
-
684
- // Render a new row field (similar to renderScheduledTableField)
753
+ // Render new row field
685
754
  const renderNewRowField = (newRowData, column, groupId = null) => {
686
755
  const value = newRowData ? newRowData[column.id] : '';
687
756
  switch (column.type) {
@@ -775,8 +844,8 @@ const GenericEditableTable = ({
775
844
  settings={{ id: column.id, compact: true }}
776
845
  className={styles.selectFullWidth}
777
846
  multi={false}
778
- onChange={(value) =>
779
- handleNewFieldChange(value, column.id, groupId)
847
+ onChange={(val) =>
848
+ handleNewFieldChange(val, column.id, groupId)
780
849
  }
781
850
  inputValue={value || ''}
782
851
  options={multiOptions}
@@ -807,7 +876,7 @@ const GenericEditableTable = ({
807
876
  }
808
877
  };
809
878
 
810
- // Render the new row form (for grouped or ungrouped data)
879
+ // Inline form for adding a new row
811
880
  const renderNewRowForm = (groupId = null) => {
812
881
  const newRowData = hasCategory ? newEntries[groupId] || {} : newEntry;
813
882
  return (
@@ -837,7 +906,7 @@ const GenericEditableTable = ({
837
906
  );
838
907
  };
839
908
 
840
- // Render header cell with a label and copy icon
909
+ // Header cell with optional copy icon
841
910
  const renderHeaderCell = (column, group = null) => (
842
911
  <th
843
912
  key={`${column.id}${group ? `-${group.id}` : ''}`}
@@ -866,11 +935,51 @@ const GenericEditableTable = ({
866
935
  </th>
867
936
  );
868
937
 
938
+ // Dataset selector at the top
939
+ const renderDatasetSelector = () =>
940
+ schedulingConfig.addDataset && (
941
+ <div className={styles.datasetSelector}>
942
+ <label htmlFor="dataset-select">Select Dataset:</label>
943
+ <select
944
+ id="dataset-select"
945
+ value={activeDatasetIndex}
946
+ onChange={(e) =>
947
+ setActiveDatasetIndex(Number(e.target.value))
948
+ }
949
+ >
950
+ {dataSets.map((ds, index) => (
951
+ <option key={index} value={index}>
952
+ {new Date(ds.created_at).toLocaleString()}
953
+ </option>
954
+ ))}
955
+ </select>
956
+ <button onClick={handleAddNewDataset}>New Dataset</button>
957
+ </div>
958
+ );
959
+
960
+ // Create a new dataset by cloning the current active one
961
+ const handleAddNewDataset = () => {
962
+ const currentActiveData = localData;
963
+ const newDataset = {
964
+ created_at: new Date().toISOString(),
965
+ data: currentActiveData.map((row) => ({ ...row })), // shallow clone
966
+ };
967
+ const updatedDataSets = [...dataSets, newDataset];
968
+ setDataSets(updatedDataSets);
969
+ // Always pick the newly created dataset
970
+ setActiveDatasetIndex(updatedDataSets.length - 1);
971
+ debouncedUpdate(updatedDataSets); // Save immediately
972
+ toast.success('New dataset added');
973
+ };
974
+
869
975
  return (
870
976
  <div className={styles.gridtxt}>
977
+ {renderDatasetSelector()}
978
+
871
979
  <div className={styles.gridtxt__header}>
872
980
  <span>{title || 'Estimation Schedule'}</span>
873
981
  </div>
982
+
874
983
  {hasCategory ? (
875
984
  groupedCategories.length > 0 ? (
876
985
  <table className={styles.schedulingTable}>
@@ -888,6 +997,7 @@ const GenericEditableTable = ({
888
997
  )}
889
998
  <th style={{ width: '5%' }}></th>
890
999
  </tr>
1000
+
891
1001
  {group.entries.map((entry, idx) => (
892
1002
  <tr
893
1003
  key={entry.id}
@@ -965,16 +1075,23 @@ const GenericEditableTable = ({
965
1075
  </td>
966
1076
  </tr>
967
1077
  ))}
968
- {/* Render the inline new row for this category */}
1078
+
969
1079
  {renderNewRowForm(group.id)}
970
- {/* Render subtotal row for group only if a total column exists */}
971
- {totalColumnIndex !== -1 && (
1080
+
1081
+ {columns.findIndex(
1082
+ (col) => col.type === 'total'
1083
+ ) !== -1 && (
972
1084
  <tr className={styles.subtotalRow}>
973
1085
  {columns.map((col, index) => {
1086
+ const totalColIndex =
1087
+ columns.findIndex(
1088
+ (c) =>
1089
+ c.type === 'total'
1090
+ );
974
1091
  if (
975
1092
  index ===
976
- totalColumnIndex - 1 &&
977
- totalColumnIndex > 0
1093
+ totalColIndex - 1 &&
1094
+ index > 0
978
1095
  ) {
979
1096
  return (
980
1097
  <td key={col.id}>
@@ -982,9 +1099,7 @@ const GenericEditableTable = ({
982
1099
  </td>
983
1100
  );
984
1101
  }
985
- if (
986
- index === totalColumnIndex
987
- ) {
1102
+ if (index === totalColIndex) {
988
1103
  const groupTotal =
989
1104
  group.entries.reduce(
990
1105
  (sum, entry) =>
@@ -992,7 +1107,7 @@ const GenericEditableTable = ({
992
1107
  calculateTotal(
993
1108
  entry,
994
1109
  columns[
995
- totalColumnIndex
1110
+ totalColIndex
996
1111
  ].keys
997
1112
  ),
998
1113
  0
@@ -1096,19 +1211,23 @@ const GenericEditableTable = ({
1096
1211
  </td>
1097
1212
  </tr>
1098
1213
  ))}
1099
- {/* Render the inline new row at the bottom */}
1214
+
1100
1215
  {renderNewRowForm()}
1101
- {/* Render overall subtotal row only if a total column exists */}
1102
- {totalColumnIndex !== -1 && (
1216
+
1217
+ {columns.findIndex((col) => col.type === 'total') !==
1218
+ -1 && (
1103
1219
  <tr className={styles.subtotalRow}>
1104
1220
  {columns.map((col, index) => {
1221
+ const totalColIndex = columns.findIndex(
1222
+ (c) => c.type === 'total'
1223
+ );
1105
1224
  if (
1106
- index === totalColumnIndex - 1 &&
1107
- totalColumnIndex > 0
1225
+ index === totalColIndex - 1 &&
1226
+ index > 0
1108
1227
  ) {
1109
1228
  return <td key={col.id}>Subtotal</td>;
1110
1229
  }
1111
- if (index === totalColumnIndex) {
1230
+ if (index === totalColIndex) {
1112
1231
  const overallTotal = sortBySortOrder(
1113
1232
  localData
1114
1233
  ).reduce(
@@ -1116,8 +1235,7 @@ const GenericEditableTable = ({
1116
1235
  sum +
1117
1236
  calculateTotal(
1118
1237
  entry,
1119
- columns[totalColumnIndex]
1120
- .keys
1238
+ columns[totalColIndex].keys
1121
1239
  ),
1122
1240
  0
1123
1241
  );
@@ -358,3 +358,51 @@
358
358
  border-top: 1px dashed rgba(var(--primary-rgb), 0.3);
359
359
  border-bottom: 1px dashed rgba(var(--primary-rgb), 0.3);
360
360
  }
361
+
362
+ .datasetSelector {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 1rem;
366
+ margin-bottom: 1rem;
367
+ padding: 0.5rem 1rem;
368
+ background: rgba(var(--primary-rgb), 0.05);
369
+ border: 1px solid rgba(var(--primary-rgb), 0.15);
370
+ border-radius: var(--br);
371
+
372
+ label {
373
+ font-size: 0.9rem;
374
+ font-weight: bold;
375
+ color: var(--paragraph-color);
376
+ }
377
+
378
+ select {
379
+ padding: 0.4rem 0.75rem;
380
+ font-size: 0.9rem;
381
+ border: 1px solid rgba(var(--primary-rgb), 0.1);
382
+ border-radius: var(--br);
383
+ background: #fff;
384
+ outline: none;
385
+ box-shadow: none;
386
+ }
387
+
388
+ /* New Dataset button styling to match .btn_compact */
389
+ button {
390
+ display: inline-block;
391
+ padding: 0.4rem 0.75rem;
392
+ font-size: 1rem;
393
+ color: var(--tertiary-color);
394
+ text-decoration: none;
395
+ background: var(--primary-color);
396
+ border: 1px solid rgba(var(--primary-color--rgb), 1.1);
397
+ border-radius: var(--br);
398
+ outline: none;
399
+ cursor: pointer;
400
+ transition: all 0.2s cubic-bezier(0.85, 0, 0.15, 1) 0s;
401
+
402
+ &:hover {
403
+ color: var(--primary-color);
404
+ background: var(--highlight-color);
405
+ border: 1px solid rgba(var(--highlight-rgb), 1.05);
406
+ }
407
+ }
408
+ }