@visns-studio/visns-components 5.7.10 → 5.7.12

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
@@ -54,11 +54,11 @@
54
54
  "reactjs-popup": "^2.0.6",
55
55
  "style-loader": "^4.0.0",
56
56
  "swapy": "^1.0.5",
57
- "sweetalert2": "^11.20.0",
57
+ "sweetalert2": "^11.21.0",
58
58
  "truncate": "^3.0.0",
59
59
  "uuid": "^11.1.0",
60
60
  "validator": "^13.15.0",
61
- "vite": "^5.4.19",
61
+ "vite": "^6.3.4",
62
62
  "yarn": "^1.22.22",
63
63
  "yet-another-react-lightbox": "^3.23.0"
64
64
  },
@@ -82,7 +82,7 @@
82
82
  "react-dom": "^17.0.0 || ^18.0.0"
83
83
  },
84
84
  "name": "@visns-studio/visns-components",
85
- "version": "5.7.10",
85
+ "version": "5.7.12",
86
86
  "description": "Various packages to assist in the development of our Custom Applications.",
87
87
  "main": "src/index.js",
88
88
  "files": [
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useState, useRef } from 'react';
2
2
  import CustomFetch from './Fetch';
3
3
  import SelectList from './SelectList';
4
- import Select, { createFilter } from 'react-select';
4
+ import Select, { createFilter, components } from 'react-select';
5
5
  import CreatableSelect from 'react-select/creatable';
6
6
  import styles from './styles/MultiSelect.module.scss';
7
7
 
@@ -17,6 +17,21 @@ const isTouchDevice = () => {
17
17
  );
18
18
  };
19
19
 
20
+ // Custom Option component to display description
21
+ const CustomOption = (props) => {
22
+ const { children, data, ...rest } = props;
23
+ return (
24
+ <components.Option {...rest}>
25
+ <div className={styles.option}>
26
+ <div className={styles.optionLabel}>{children}</div>
27
+ {data.description && (
28
+ <div className={styles.optionDescription}>{data.description}</div>
29
+ )}
30
+ </div>
31
+ </components.Option>
32
+ );
33
+ };
34
+
20
35
  function MultiSelect({
21
36
  className,
22
37
  inputValue = [], // Default to an empty array
@@ -55,6 +70,8 @@ function MultiSelect({
55
70
  a.name ||
56
71
  a.description ||
57
72
  'Unknown',
73
+ // Make sure description is preserved
74
+ description: a.description || null,
58
75
  ...a,
59
76
  };
60
77
  }
@@ -78,6 +95,8 @@ function MultiSelect({
78
95
  ? inputValue.map((a) => ({
79
96
  value: a.id,
80
97
  label: a.label || a.name || a.description || 'Unknown',
98
+ // Make sure description is preserved
99
+ description: a.description || null,
81
100
  ...a,
82
101
  }))
83
102
  : inputValue && typeof inputValue === 'object'
@@ -89,6 +108,8 @@ function MultiSelect({
89
108
  inputValue.name ||
90
109
  inputValue.description ||
91
110
  'Unknown',
111
+ // Make sure description is preserved
112
+ description: inputValue.description || null,
92
113
  ...inputValue,
93
114
  },
94
115
  ]
@@ -110,6 +131,8 @@ function MultiSelect({
110
131
  const newOption = {
111
132
  value: res.data.id,
112
133
  label: res.data.label || inputValue,
134
+ // Make sure description is preserved if it exists in the response
135
+ description: res.data.description || null,
113
136
  ...res.data,
114
137
  };
115
138
 
@@ -146,7 +169,10 @@ function MultiSelect({
146
169
  isSearchable={isSearchable}
147
170
  isMulti={multi}
148
171
  filterOption={createFilter({ ignoreAccents: false })}
149
- components={{ SelectList }}
172
+ components={{
173
+ SelectList,
174
+ Option: CustomOption
175
+ }}
150
176
  options={selectOptions}
151
177
  menuPortalTarget={document.body}
152
178
  menuPlacement={menuPlacement}
@@ -179,7 +205,7 @@ function MultiSelect({
179
205
  }),
180
206
  option: (base) => ({
181
207
  ...base,
182
- padding: isTouchDevice() ? '12px 15px' : base.padding, // Larger padding for touch devices
208
+ padding: isTouchDevice() ? '12px 15px' : '8px 12px', // Larger padding for touch devices
183
209
  }),
184
210
  menuPortal: (base) => ({
185
211
  ...base,
@@ -4,13 +4,15 @@ import { FixedSizeList } from 'react-window';
4
4
  function SelectList(props) {
5
5
  const { options, children, maxHeight, getValue } = props;
6
6
  const [value] = getValue();
7
- const initialOffset = options.indexOf(value) * 50;
7
+ // Increase item size to accommodate description
8
+ const itemSize = 60; // Increased from 50 to allow space for description
9
+ const initialOffset = options.indexOf(value) * itemSize;
8
10
 
9
11
  return (
10
12
  <FixedSizeList
11
13
  height={maxHeight}
12
14
  itemCount={children.length}
13
- itemSize={height}
15
+ itemSize={itemSize}
14
16
  initialScrollOffset={initialOffset}
15
17
  >
16
18
  {({ index, style }) => <div style={style}>{children[index]}</div>}
@@ -7,11 +7,14 @@ import moment from 'moment';
7
7
  import get from 'lodash/get';
8
8
  import { saveAs } from 'file-saver';
9
9
  import { toast } from 'react-toastify';
10
+ import { arrayMoveImmutable } from 'array-move';
11
+ import { confirmDialog } from '../../utils/ConfirmDialog';
10
12
 
11
13
  import CustomFetch from '../../crm/Fetch';
12
14
  import ActionButtons from './ActionButtons';
13
15
  import Form from '../../crm/Form';
14
16
  import { formatCurrency } from './utils/formatters';
17
+ import SortableTableBody from './SortableQuoteItems';
15
18
 
16
19
  import styles from './styles/GenericQuote.module.scss';
17
20
 
@@ -31,17 +34,309 @@ function GenericQuote({ logo, setting, urlParam }) {
31
34
  const [formData, setFormData] = useState({});
32
35
 
33
36
  // Modal functions
34
- const modalOpen = (type, id) => {
35
- setModalShow(true);
37
+ const modalOpen = (type, id, customFormSettings = null, itemData = null) => {
38
+ // Debug logging to help troubleshoot form settings issues
39
+ console.log('Opening modal with form settings:', {
40
+ type,
41
+ id,
42
+ hasCustomSettings: !!customFormSettings,
43
+ customFormSettings,
44
+ defaultForm: form,
45
+ itemData
46
+ });
47
+
48
+ // Use custom form settings if provided, otherwise use the default form
49
+ let formSettings = customFormSettings || form;
50
+
51
+ // Ensure the form settings have the correct structure
52
+ // The Form component expects a formSettings object with a 'create' or 'update' property
53
+ // depending on the formType, and a 'fields' array
54
+ if (formSettings && !formSettings[type] && type === 'create') {
55
+ // If the form settings don't have a 'create' property but we're opening a create form,
56
+ // add a default 'create' property
57
+ formSettings = {
58
+ ...formSettings,
59
+ create: {
60
+ title: customFormSettings?.create?.title || 'Create New',
61
+ submitUrl: customFormSettings?.create?.submitUrl || '/api/submit',
62
+ method: customFormSettings?.create?.method || 'POST'
63
+ }
64
+ };
65
+ }
66
+
67
+ // If we have item data, add it to the form settings
68
+ if (itemData && type === 'update') {
69
+ formSettings = {
70
+ ...formSettings,
71
+ initialData: itemData
72
+ };
73
+ }
74
+
75
+ console.log('Final form settings:', formSettings);
76
+
77
+ // Set the state variables to open the modal
78
+ setFormData(formSettings);
36
79
  setFormType(type);
37
80
  setFormId(id);
38
- setFormData(form);
81
+ setModalShow(true);
39
82
  };
40
83
 
41
84
  const modalClose = () => {
42
85
  setModalShow(false);
43
86
  };
44
87
 
88
+ // Item sorting, editing, and deleting functions
89
+ const handleSortEnd = (tableId, oldIndex, newIndex) => {
90
+ if (oldIndex === newIndex) return;
91
+
92
+ // Create a copy of the data
93
+ const newData = { ...data };
94
+
95
+ // Reorder the items in the specified table
96
+ newData[tableId] = arrayMoveImmutable(newData[tableId], oldIndex, newIndex);
97
+
98
+ // Update the state immediately for better UX
99
+ setData(newData);
100
+
101
+ // Find the table configuration
102
+ const tableConfig = setting.tables.find(t => t.id === tableId);
103
+ if (!tableConfig || !tableConfig.urls || !tableConfig.urls.sort) {
104
+ console.error('Sort URL not found for table:', tableId);
105
+ toast.error('Failed to update item order: Missing configuration');
106
+ return;
107
+ }
108
+
109
+ // Get the sort URL and key from the table configuration
110
+ const { url, key } = tableConfig.urls.sort;
111
+
112
+ // Get CSRF token
113
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
114
+
115
+ // Show a loading toast
116
+ const toastId = toast.loading('Updating item order...');
117
+
118
+ // Create an array of promises for each item update
119
+ const updatePromises = newData[tableId].map((item, index) => {
120
+ // For each item, create a request that only sends the sort_order
121
+ const itemId = item[tableConfig.dataId];
122
+ const sortOrder = index + 1; // 1-based index for sort order
123
+
124
+ // Create a request body with just the sort_order key
125
+ const requestBody = {
126
+ [key]: sortOrder
127
+ };
128
+
129
+ // Send individual request for this item
130
+ return fetch(`${url}/${itemId}`, {
131
+ method: 'POST',
132
+ headers: {
133
+ 'Content-Type': 'application/json',
134
+ 'X-Requested-With': 'XMLHttpRequest',
135
+ 'X-CSRF-TOKEN': csrfToken,
136
+ },
137
+ body: JSON.stringify(requestBody)
138
+ })
139
+ .then(response => {
140
+ if (!response.ok) {
141
+ throw new Error(`Failed to update item ${itemId}`);
142
+ }
143
+ return response.json();
144
+ });
145
+ });
146
+
147
+ // Wait for all requests to complete
148
+ Promise.all(updatePromises)
149
+ .then(results => {
150
+ console.log('All items updated successfully:', results);
151
+
152
+ // Update toast
153
+ toast.update(toastId, {
154
+ render: 'Item order updated successfully',
155
+ type: 'success',
156
+ isLoading: false,
157
+ autoClose: 3000,
158
+ closeButton: true,
159
+ });
160
+ })
161
+ .catch(error => {
162
+ console.error('Error updating order:', error);
163
+
164
+ // Update toast
165
+ toast.update(toastId, {
166
+ render: `Failed to update item order: ${error.message}`,
167
+ type: 'error',
168
+ isLoading: false,
169
+ autoClose: 5000,
170
+ closeButton: true,
171
+ });
172
+
173
+ // Revert to the original order in case of error
174
+ setData(data);
175
+ });
176
+ };
177
+
178
+ const handleEditItem = (tableId, row, index) => {
179
+ console.log('Edit item:', { tableId, row, index });
180
+
181
+ // Find the table configuration
182
+ const tableConfig = setting.tables.find(t => t.id === tableId);
183
+ if (!tableConfig) {
184
+ console.error('Table configuration not found for:', tableId);
185
+ toast.error('Failed to edit item: Missing configuration');
186
+ return;
187
+ }
188
+
189
+ // Find the form settings for the table
190
+ const tableForm = tableConfig.form || form;
191
+
192
+ // Get the item ID
193
+ const itemId = row[tableConfig.dataId];
194
+
195
+ // If there's a get URL for fetching item details
196
+ if (tableConfig.urls && tableConfig.urls.get) {
197
+ // Get CSRF token
198
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
199
+
200
+ // Fetch the item details before opening the modal
201
+ // Laravel uses GET for retrieving data
202
+ fetch(`${tableConfig.urls.get}${tableConfig.dataId}/${itemId}`, {
203
+ method: 'GET',
204
+ headers: {
205
+ 'Content-Type': 'application/json',
206
+ 'X-Requested-With': 'XMLHttpRequest',
207
+ 'X-CSRF-TOKEN': csrfToken,
208
+ }
209
+ })
210
+ .then(response => {
211
+ if (!response.ok) {
212
+ throw new Error('Failed to fetch item details');
213
+ }
214
+ return response.json();
215
+ })
216
+ .then(itemData => {
217
+ console.log('Fetched item data:', itemData);
218
+ // Open the modal with the update form type and the fetched data
219
+ // Use the update URL from the table configuration for the form
220
+ const updatedFormSettings = {
221
+ ...tableForm,
222
+ update: {
223
+ ...tableForm.update,
224
+ submitUrl: tableConfig.urls.update + tableConfig.dataId + '/' + itemId,
225
+ method: 'PUT' // Laravel uses PUT for updates
226
+ }
227
+ };
228
+ modalOpen('update', itemId, updatedFormSettings, itemData);
229
+ })
230
+ .catch(error => {
231
+ console.error('Error fetching item details:', error);
232
+ toast.error('Failed to fetch item details: ' + error.message);
233
+
234
+ // Fall back to opening the modal with the row data
235
+ modalOpen('update', itemId, tableForm, row);
236
+ });
237
+ } else {
238
+ // Open the modal with the update form type
239
+ modalOpen('update', itemId, tableForm, row);
240
+ }
241
+ };
242
+
243
+ const handleDeleteItem = (tableId, row, index) => {
244
+ console.log('Delete item:', { tableId, row, index });
245
+
246
+ // Find the table configuration
247
+ const tableConfig = setting.tables.find(t => t.id === tableId);
248
+ if (!tableConfig || !tableConfig.urls || !tableConfig.urls.delete) {
249
+ console.error('Delete URL not found for table:', tableId);
250
+ toast.error('Failed to delete item: Missing configuration');
251
+ return;
252
+ }
253
+
254
+ // Get the item ID
255
+ const itemId = row[tableConfig.dataId];
256
+
257
+ // Show a confirmation dialog
258
+ confirmDialog({
259
+ title: 'Delete Item',
260
+ message: 'Are you sure you want to delete this item?',
261
+ confirmLabel: 'Delete',
262
+ cancelLabel: 'Cancel',
263
+ onConfirm: () => {
264
+ // Get CSRF token
265
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
266
+
267
+ // Laravel requires a special approach for DELETE requests
268
+ // We need to include the _method parameter in the request body
269
+ const formData = new FormData();
270
+ formData.append('_method', 'DELETE');
271
+
272
+ // Send the delete request to the server
273
+ fetch(`${tableConfig.urls.delete}${tableConfig.dataId}/${itemId}`, {
274
+ method: 'POST', // Actually sending as POST but Laravel will interpret as DELETE
275
+ headers: {
276
+ 'X-Requested-With': 'XMLHttpRequest',
277
+ 'X-CSRF-TOKEN': csrfToken,
278
+ },
279
+ body: formData
280
+ })
281
+ .then(response => {
282
+ if (!response.ok) {
283
+ throw new Error('Failed to delete item');
284
+ }
285
+ return response.json();
286
+ })
287
+ .then(responseData => {
288
+ console.log('Item deleted successfully:', responseData);
289
+
290
+ // Create a copy of the data
291
+ const newData = { ...data };
292
+
293
+ // Remove the item from the specified table
294
+ newData[tableId] = newData[tableId].filter(item => item[tableConfig.dataId] !== itemId);
295
+
296
+ // Update the state
297
+ setData(newData);
298
+
299
+ // Show a success toast
300
+ toast.success('Item deleted successfully');
301
+ })
302
+ .catch(error => {
303
+ console.error('Error deleting item:', error);
304
+ toast.error('Failed to delete item: ' + error.message);
305
+ });
306
+ }
307
+ });
308
+ };
309
+
310
+ const handleAddItem = (tableId) => {
311
+ console.log('Add item to table:', tableId);
312
+
313
+ // Find the table configuration
314
+ const tableConfig = setting.tables.find(t => t.id === tableId);
315
+ if (!tableConfig) {
316
+ console.error('Table configuration not found for:', tableId);
317
+ toast.error('Failed to add item: Missing configuration');
318
+ return;
319
+ }
320
+
321
+ // Find the form settings for the table
322
+ const tableForm = tableConfig.form || form;
323
+
324
+ // Update the form settings to include the dataId in the submitUrl
325
+ const updatedFormSettings = {
326
+ ...tableForm,
327
+ create: {
328
+ ...tableForm.create,
329
+ submitUrl: tableForm.create?.submitUrl
330
+ ? `${tableForm.create.submitUrl}${tableConfig.dataId}`
331
+ : `/ajax/quoteItems/${tableConfig.dataId}`,
332
+ method: 'POST' // Laravel uses POST for creating new items
333
+ }
334
+ };
335
+
336
+ // Open the modal with the create form type and updated form settings
337
+ modalOpen('create', 0, updatedFormSettings);
338
+ };
339
+
45
340
  useEffect(() => {
46
341
  if (actionItems && actionItems.length > 0) {
47
342
  setActions(actionItems);
@@ -108,6 +403,15 @@ function GenericQuote({ logo, setting, urlParam }) {
108
403
  return;
109
404
  }
110
405
 
406
+ // Handle modal or addModal type - opens a form modal
407
+ if (action.type === 'modal' || action.type === 'addModal') {
408
+ console.log('Opening modal form with action:', action);
409
+ // Open the modal with 'create' type to indicate a new submission
410
+ // Use the form settings from the action if provided
411
+ modalOpen('create', 0, action.setting); // Using 0 as the ID since it's a new form
412
+ return;
413
+ }
414
+
111
415
  // Handle generatePdf type - generates PDF from a view
112
416
  if (action.type === 'generatePdf' && action.view) {
113
417
  console.log('Generating PDF from view:', action.view);
@@ -200,6 +504,7 @@ function GenericQuote({ logo, setting, urlParam }) {
200
504
  modalOpen('update', data.id);
201
505
  }
202
506
  break;
507
+ // Note: 'addModal' is now handled as a type, not an id
203
508
  case 'saveAsPdf':
204
509
  // If no type is specified but id is saveAsPdf, default to PDF generation
205
510
  if (!action.type) {
@@ -532,15 +837,24 @@ function GenericQuote({ logo, setting, urlParam }) {
532
837
  key={`table-section-${table.id}`}
533
838
  className={styles.quoteTableSection}
534
839
  >
535
- <h2 className={styles.tableTitle}>
536
- {table.label}
537
- </h2>
840
+ <div className={styles.tableTitleRow}>
841
+ <h2 className={styles.tableTitle}>
842
+ {table.label}
843
+ </h2>
844
+ <button
845
+ className={styles.addItemButton}
846
+ onClick={() => handleAddItem(table.id)}
847
+ >
848
+ + Add Item
849
+ </button>
850
+ </div>
538
851
  <table
539
852
  key={`table-${table.id}`}
540
853
  className={styles.quoteTable}
541
854
  >
542
855
  <thead>
543
856
  <tr>
857
+ <th className={styles.dragHandleHeader}></th>
544
858
  {table.columns.map((column) => (
545
859
  <th
546
860
  key={`${table.id}-column-${column.id}`}
@@ -557,73 +871,22 @@ function GenericQuote({ logo, setting, urlParam }) {
557
871
  : column.label}
558
872
  </th>
559
873
  ))}
874
+ <th className={styles.actionHeader}>Actions</th>
560
875
  </tr>
561
876
  </thead>
562
- <tbody>
563
- {data[table.id].map(
564
- (row, index) => (
565
- <tr
566
- key={`row-${index}`}
567
- className={
568
- styles.tableRow
569
- }
570
- >
571
- {table.columns.map(
572
- (column) => {
573
- const cellValue =
574
- get(
575
- row,
576
- column.id,
577
- ''
578
- );
579
-
580
- return (
581
- <td
582
- key={`row-${index}-column-${column.id}`}
583
- style={
584
- column.style ||
585
- {}
586
- }
587
- className={
588
- styles.tableCell
589
- }
590
- >
591
- {column.id ===
592
- 'subtotal' ||
593
- column.label ===
594
- 'Subtotal'
595
- ? formatCurrency(
596
- parseFloat(
597
- get(
598
- row,
599
- 'rate',
600
- 0
601
- )
602
- ) *
603
- parseFloat(
604
- get(
605
- row,
606
- 'qty',
607
- 1
608
- )
609
- )
610
- )
611
- : column.type ===
612
- 'currency'
613
- ? formatCurrency(
614
- cellValue
615
- )
616
- : cellValue}
617
- </td>
618
- );
619
- }
620
- )}
621
- </tr>
622
- )
623
- )}
624
-
625
- {/* Total row */}
877
+ <SortableTableBody
878
+ items={data[table.id]}
879
+ columns={table.columns}
880
+ onSortEnd={({oldIndex, newIndex}) => handleSortEnd(table.id, oldIndex, newIndex)}
881
+ onEdit={(row, index) => handleEditItem(table.id, row, index)}
882
+ onDelete={(row, index) => handleDeleteItem(table.id, row, index)}
883
+ useDragHandle
884
+ helperClass="sortableHelper"
885
+ distance={5}
886
+ />
887
+ <tfoot>
626
888
  <tr className={styles.subtotalRow}>
889
+ <td></td> {/* Empty cell for drag handle */}
627
890
  <td
628
891
  colSpan={
629
892
  table.columns.length - 1
@@ -641,8 +904,9 @@ function GenericQuote({ logo, setting, urlParam }) {
641
904
  >
642
905
  {formatCurrency(subtotal)}
643
906
  </td>
907
+ <td></td> {/* Empty cell for action buttons */}
644
908
  </tr>
645
- </tbody>
909
+ </tfoot>
646
910
  </table>
647
911
  </div>
648
912
  );
@@ -0,0 +1,87 @@
1
+ import React from 'react';
2
+ import { SortableContainer, SortableElement, sortableHandle } from 'react-sortable-hoc';
3
+ import { ChevronVertical, Pencil, TrashCan } from 'akar-icons';
4
+ import get from 'lodash/get';
5
+ import { formatCurrency } from './utils/formatters';
6
+
7
+ import styles from './styles/SortableQuoteItems.module.scss';
8
+
9
+ // Drag handle component
10
+ const DragHandle = sortableHandle(() => (
11
+ <ChevronVertical className={styles.dragHandle} strokeWidth={1} size={22} />
12
+ ));
13
+
14
+ // Sortable table row component
15
+ const SortableRow = SortableElement(({ row, index, columns, onEdit, onDelete }) => {
16
+ return (
17
+ <tr className={styles.sortableRow}>
18
+ <td className={styles.dragHandleCell}>
19
+ <DragHandle />
20
+ </td>
21
+ {columns.map((column) => {
22
+ const cellValue = get(row, column.id, '');
23
+
24
+ return (
25
+ <td
26
+ key={`row-${index}-column-${column.id}`}
27
+ style={column.style || {}}
28
+ className={styles.tableCell}
29
+ >
30
+ {column.id === 'subtotal' || column.label === 'Subtotal'
31
+ ? formatCurrency(
32
+ parseFloat(get(row, 'rate', 0)) *
33
+ parseFloat(get(row, 'qty', 1))
34
+ )
35
+ : column.type === 'currency'
36
+ ? formatCurrency(cellValue)
37
+ : cellValue}
38
+ </td>
39
+ );
40
+ })}
41
+ <td className={styles.actionCell}>
42
+ <div className={styles.actionButtons}>
43
+ <Pencil
44
+ className={styles.editButton}
45
+ strokeWidth={2}
46
+ size={20}
47
+ onClick={(e) => {
48
+ e.preventDefault();
49
+ e.stopPropagation();
50
+ onEdit(row, index);
51
+ }}
52
+ />
53
+ <TrashCan
54
+ className={styles.deleteButton}
55
+ strokeWidth={2}
56
+ size={20}
57
+ onClick={(e) => {
58
+ e.preventDefault();
59
+ e.stopPropagation();
60
+ onDelete(row, index);
61
+ }}
62
+ />
63
+ </div>
64
+ </td>
65
+ </tr>
66
+ );
67
+ });
68
+
69
+ // Sortable table body component
70
+ const SortableTableBody = SortableContainer(({ items, columns, onEdit, onDelete }) => {
71
+ return (
72
+ <tbody>
73
+ {items.map((row, index) => (
74
+ <SortableRow
75
+ key={`row-${index}`}
76
+ index={index}
77
+ row={row}
78
+ columns={columns}
79
+ onEdit={onEdit}
80
+ onDelete={onDelete}
81
+ />
82
+ ))}
83
+ </tbody>
84
+ );
85
+ });
86
+
87
+ export default SortableTableBody;
@@ -549,15 +549,42 @@
549
549
  margin-bottom: 25px;
550
550
  }
551
551
 
552
+ .tableTitleRow {
553
+ display: flex;
554
+ justify-content: space-between;
555
+ align-items: center;
556
+ background-color: var(--primary-color, #1a3d66);
557
+ padding: 0 15px 0 0;
558
+ }
559
+
552
560
  .tableTitle {
553
561
  font-size: 1rem;
554
562
  font-weight: 600;
555
563
  color: var(--tertiary-color, white);
556
564
  margin: 0;
557
565
  padding: 10px 15px;
558
- background-color: var(--primary-color, #1a3d66);
566
+ background-color: transparent;
559
567
  border-radius: 0;
560
568
  border: none;
569
+ flex: 1;
570
+ }
571
+
572
+ .addItemButton {
573
+ background-color: var(--tertiary-color, white);
574
+ color: var(--primary-color, #1a3d66);
575
+ border: none;
576
+ border-radius: 4px;
577
+ padding: 6px 12px;
578
+ font-size: 0.85rem;
579
+ font-weight: 600;
580
+ cursor: pointer;
581
+ transition: all 0.2s ease;
582
+ display: flex;
583
+ align-items: center;
584
+
585
+ &:hover {
586
+ background-color: #f0f0f0;
587
+ }
561
588
  }
562
589
 
563
590
  .quoteTable {
@@ -599,6 +626,25 @@
599
626
  }
600
627
  }
601
628
 
629
+ .dragHandleHeader {
630
+ width: 30px;
631
+ background-color: #4fbfa5;
632
+ color: white;
633
+ padding: 10px 5px !important;
634
+ border-bottom: none;
635
+ }
636
+
637
+ .actionHeader {
638
+ width: 80px;
639
+ background-color: #4fbfa5;
640
+ color: white;
641
+ text-align: center;
642
+ font-weight: 600;
643
+ font-size: 0.85rem;
644
+ padding: 10px 15px;
645
+ border-bottom: none;
646
+ }
647
+
602
648
  .tableRow {
603
649
  border-bottom: 1px solid rgba(var(--primary-rgb), 0.05);
604
650
  transition: background-color 0.2s ease;
@@ -752,11 +798,20 @@
752
798
  font-size: 0.85rem;
753
799
  }
754
800
 
801
+ .tableTitleRow {
802
+ padding: 0 10px 0 0;
803
+ }
804
+
755
805
  .tableTitle {
756
806
  font-size: 0.9rem;
757
807
  padding: 8px 12px;
758
808
  }
759
809
 
810
+ .addItemButton {
811
+ padding: 4px 8px;
812
+ font-size: 0.75rem;
813
+ }
814
+
760
815
  .quoteTable {
761
816
  font-size: 0.8rem;
762
817
  }
@@ -470,8 +470,8 @@
470
470
  border-color: var(--primary-color, #3b82f6);
471
471
 
472
472
  &:hover {
473
- background-color: darken(#3b82f6, 5%);
474
- border-color: darken(#3b82f6, 5%);
473
+ background-color: color.adjust(#3b82f6, $lightness: -5%);
474
+ border-color: color.adjust(#3b82f6, $lightness: -5%);
475
475
  }
476
476
 
477
477
  .operatorDescription {
@@ -519,7 +519,7 @@
519
519
  color: white;
520
520
 
521
521
  &:hover {
522
- background-color: darken(#3b82f6, 5%);
522
+ background-color: color.adjust(#3b82f6, $lightness: -5%);
523
523
  }
524
524
  }
525
525
 
@@ -0,0 +1,101 @@
1
+ .sortableRow {
2
+ position: relative;
3
+ transition: background-color 0.2s ease;
4
+ cursor: grab;
5
+ border-bottom: 1px solid rgba(var(--primary-rgb), 0.05);
6
+ background-color: white;
7
+
8
+ &:hover {
9
+ background-color: rgba(var(--primary-rgb), 0.02);
10
+ }
11
+
12
+ &:last-child {
13
+ border-bottom: none;
14
+ }
15
+
16
+ &:nth-child(odd) {
17
+ background-color: #f9f9f9;
18
+ }
19
+ }
20
+
21
+ .dragHandleCell {
22
+ width: 30px;
23
+ padding: 8px 4px !important;
24
+ vertical-align: middle;
25
+ }
26
+
27
+ .dragHandle {
28
+ color: #4fbfa5;
29
+ cursor: grab;
30
+ opacity: 0.7;
31
+ transition: opacity 0.2s ease;
32
+
33
+ &:hover {
34
+ opacity: 1;
35
+ }
36
+ }
37
+
38
+ .tableCell {
39
+ padding: 12px 15px;
40
+ color: var(--paragraph-color);
41
+ font-size: 0.85rem;
42
+ line-height: 1.4;
43
+ vertical-align: middle;
44
+ }
45
+
46
+ .actionCell {
47
+ width: 80px;
48
+ padding: 12px 15px !important;
49
+ vertical-align: middle;
50
+ text-align: right;
51
+ }
52
+
53
+ .actionButtons {
54
+ display: flex;
55
+ gap: 12px;
56
+ justify-content: flex-end;
57
+ align-items: center;
58
+ }
59
+
60
+ .editButton, .deleteButton {
61
+ cursor: pointer;
62
+ transition: color 0.2s ease;
63
+
64
+ &:hover {
65
+ transform: scale(1.1);
66
+ }
67
+ }
68
+
69
+ .editButton {
70
+ color: #4fbfa5;
71
+
72
+ &:hover {
73
+ color: var(--secondary-color);
74
+ }
75
+ }
76
+
77
+ .deleteButton {
78
+ color: #4fbfa5;
79
+
80
+ &:hover {
81
+ color: #d32f2f;
82
+ }
83
+ }
84
+
85
+ /* Helper class for the dragged item */
86
+ :global(.sortableHelper) {
87
+ display: table;
88
+ table-layout: fixed;
89
+ width: 100%;
90
+ background-color: white;
91
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
92
+ border-radius: 4px;
93
+ opacity: 0.9;
94
+ z-index: 1000;
95
+
96
+ td {
97
+ background-color: white;
98
+ border-top: 1px solid rgba(var(--primary-rgb), 0.1);
99
+ border-bottom: 1px solid rgba(var(--primary-rgb), 0.1);
100
+ }
101
+ }
@@ -11,6 +11,25 @@
11
11
  box-shadow: none !important;
12
12
  }
13
13
 
14
+ .option {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 2px;
18
+ }
19
+
20
+ .optionLabel {
21
+ font-size: 14px;
22
+ font-weight: 500;
23
+ }
24
+
25
+ .optionDescription {
26
+ font-size: 12px;
27
+ color: #6b7280;
28
+ font-weight: 400;
29
+ line-height: 1.2;
30
+ margin-top: 2px;
31
+ }
32
+
14
33
  /* Global styles for react-select to ensure it works in modals */
15
34
  :global {
16
35
  .visns-select__menu-portal {
@@ -84,6 +103,11 @@
84
103
  overflow: visible !important;
85
104
  }
86
105
 
106
+ /* Option styling for description */
107
+ .visns-select__option {
108
+ padding: 8px 12px !important;
109
+ }
110
+
87
111
  .css-1fdsijx-ValueContainer {
88
112
  padding: 2px 8px !important;
89
113
  flex-wrap: wrap !important;
@@ -0,0 +1,67 @@
1
+ import { toast } from 'react-toastify';
2
+
3
+ /**
4
+ * A simple confirmation dialog using toast notifications
5
+ * @param {Object} options - Configuration options
6
+ * @param {string} options.title - Dialog title
7
+ * @param {string} options.message - Dialog message
8
+ * @param {string} options.confirmLabel - Label for the confirm button
9
+ * @param {string} options.cancelLabel - Label for the cancel button
10
+ * @param {Function} options.onConfirm - Function to call when confirmed
11
+ * @param {Function} options.onCancel - Function to call when cancelled
12
+ */
13
+ export const confirmDialog = ({
14
+ title = 'Confirm',
15
+ message = 'Are you sure?',
16
+ confirmLabel = 'Yes',
17
+ cancelLabel = 'No',
18
+ onConfirm = () => {},
19
+ onCancel = () => {}
20
+ }) => {
21
+ // Create a custom toast with buttons
22
+ toast.info(
23
+ <div>
24
+ <div style={{ fontWeight: 'bold', marginBottom: '8px' }}>{title}</div>
25
+ <div style={{ marginBottom: '16px' }}>{message}</div>
26
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
27
+ <button
28
+ onClick={() => {
29
+ toast.dismiss();
30
+ onCancel();
31
+ }}
32
+ style={{
33
+ padding: '6px 12px',
34
+ background: '#f5f5f5',
35
+ border: '1px solid #ddd',
36
+ borderRadius: '4px',
37
+ cursor: 'pointer'
38
+ }}
39
+ >
40
+ {cancelLabel}
41
+ </button>
42
+ <button
43
+ onClick={() => {
44
+ toast.dismiss();
45
+ onConfirm();
46
+ }}
47
+ style={{
48
+ padding: '6px 12px',
49
+ background: '#d32f2f',
50
+ color: 'white',
51
+ border: 'none',
52
+ borderRadius: '4px',
53
+ cursor: 'pointer'
54
+ }}
55
+ >
56
+ {confirmLabel}
57
+ </button>
58
+ </div>
59
+ </div>,
60
+ {
61
+ autoClose: false,
62
+ closeButton: false,
63
+ closeOnClick: false,
64
+ draggable: false
65
+ }
66
+ );
67
+ };