@visns-studio/visns-components 5.7.11 → 5.7.13

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
@@ -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.11",
85
+ "version": "5.7.13",
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,36 @@ 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
+
35
+ // Custom MultiValue component to display description for selected values
36
+ const CustomMultiValue = (props) => {
37
+ const { children, data, ...rest } = props;
38
+ return (
39
+ <components.MultiValue {...rest}>
40
+ <div className={styles.multiValue}>
41
+ <div className={styles.multiValueLabel}>{children}</div>
42
+ {data.description && (
43
+ <div className={styles.multiValueDescription}>{data.description}</div>
44
+ )}
45
+ </div>
46
+ </components.MultiValue>
47
+ );
48
+ };
49
+
20
50
  function MultiSelect({
21
51
  className,
22
52
  inputValue = [], // Default to an empty array
@@ -55,6 +85,8 @@ function MultiSelect({
55
85
  a.name ||
56
86
  a.description ||
57
87
  'Unknown',
88
+ // Make sure description is preserved
89
+ description: a.description || null,
58
90
  ...a,
59
91
  };
60
92
  }
@@ -74,12 +106,36 @@ function MultiSelect({
74
106
  JSON.stringify(prevInputValueRef.current) !==
75
107
  JSON.stringify(inputValue)
76
108
  ) {
109
+ // Find matching options to get descriptions if not present in inputValue
110
+ const findMatchingOption = (item) => {
111
+ if (item.description) return item;
112
+
113
+ // Try to find a matching option with description
114
+ const matchingOption = selectOptions.find(opt =>
115
+ opt.id === item.id || opt.value === item.id
116
+ );
117
+
118
+ if (matchingOption && matchingOption.description) {
119
+ return {
120
+ ...item,
121
+ description: matchingOption.description
122
+ };
123
+ }
124
+
125
+ return item;
126
+ };
127
+
77
128
  const _values = Array.isArray(inputValue)
78
- ? inputValue.map((a) => ({
79
- value: a.id,
80
- label: a.label || a.name || a.description || 'Unknown',
81
- ...a,
82
- }))
129
+ ? inputValue.map((a) => {
130
+ const enrichedItem = findMatchingOption(a);
131
+ return {
132
+ value: enrichedItem.id,
133
+ label: enrichedItem.label || enrichedItem.name || enrichedItem.description || 'Unknown',
134
+ // Make sure description is preserved
135
+ description: enrichedItem.description || null,
136
+ ...enrichedItem,
137
+ };
138
+ })
83
139
  : inputValue && typeof inputValue === 'object'
84
140
  ? [
85
141
  {
@@ -89,14 +145,17 @@ function MultiSelect({
89
145
  inputValue.name ||
90
146
  inputValue.description ||
91
147
  'Unknown',
148
+ // Make sure description is preserved
149
+ description: inputValue.description || null,
92
150
  ...inputValue,
93
151
  },
94
152
  ]
95
153
  : [];
154
+
96
155
  setSelectValue(_values);
97
156
  prevInputValueRef.current = inputValue;
98
157
  }
99
- }, [inputValue]);
158
+ }, [inputValue, selectOptions]);
100
159
 
101
160
  const handleCreate = async (inputValue) => {
102
161
  if (creatableConfig.url && creatableConfig.method) {
@@ -110,6 +169,8 @@ function MultiSelect({
110
169
  const newOption = {
111
170
  value: res.data.id,
112
171
  label: res.data.label || inputValue,
172
+ // Make sure description is preserved if it exists in the response
173
+ description: res.data.description || null,
113
174
  ...res.data,
114
175
  };
115
176
 
@@ -146,7 +207,11 @@ function MultiSelect({
146
207
  isSearchable={isSearchable}
147
208
  isMulti={multi}
148
209
  filterOption={createFilter({ ignoreAccents: false })}
149
- components={{ SelectList }}
210
+ components={{
211
+ SelectList,
212
+ Option: CustomOption,
213
+ MultiValue: CustomMultiValue
214
+ }}
150
215
  options={selectOptions}
151
216
  menuPortalTarget={document.body}
152
217
  menuPlacement={menuPlacement}
@@ -179,7 +244,7 @@ function MultiSelect({
179
244
  }),
180
245
  option: (base) => ({
181
246
  ...base,
182
- padding: isTouchDevice() ? '12px 15px' : base.padding, // Larger padding for touch devices
247
+ padding: isTouchDevice() ? '12px 15px' : '8px 12px', // Larger padding for touch devices
183
248
  }),
184
249
  menuPortal: (base) => ({
185
250
  ...base,
@@ -233,6 +298,24 @@ function MultiSelect({
233
298
  multiValue: (base) => ({
234
299
  ...base,
235
300
  margin: '2px',
301
+ padding: '2px 4px',
302
+ minHeight: '30px',
303
+ display: 'flex',
304
+ flexDirection: 'column',
305
+ alignItems: 'flex-start',
306
+ }),
307
+ multiValueLabel: (base) => ({
308
+ ...base,
309
+ padding: '0',
310
+ display: 'flex',
311
+ flexDirection: 'column',
312
+ width: '100%',
313
+ }),
314
+ multiValueRemove: (base) => ({
315
+ ...base,
316
+ padding: '0 4px',
317
+ alignSelf: 'flex-start',
318
+ marginTop: '2px',
236
319
  }),
237
320
  }}
238
321
  value={selectValue}
@@ -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
  }
@@ -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,47 @@
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
+
33
+ .multiValue {
34
+ display: flex;
35
+ flex-direction: column;
36
+ gap: 1px;
37
+ }
38
+
39
+ .multiValueLabel {
40
+ font-size: 13px;
41
+ font-weight: 500;
42
+ }
43
+
44
+ .multiValueDescription {
45
+ font-size: 11px;
46
+ color: rgba(0, 0, 0, 0.7); /* Darker color for better contrast */
47
+ font-weight: 400;
48
+ line-height: 1.1;
49
+ background-color: rgba(255, 255, 255, 0.7); /* Semi-transparent white background */
50
+ padding: 1px 3px;
51
+ border-radius: 2px;
52
+ margin-top: 1px;
53
+ }
54
+
14
55
  /* Global styles for react-select to ensure it works in modals */
15
56
  :global {
16
57
  .visns-select__menu-portal {
@@ -65,6 +106,24 @@
65
106
  /* Multi-value styles */
66
107
  .visns-select__multi-value {
67
108
  margin: 2px !important;
109
+ padding: 2px 4px !important;
110
+ min-height: 30px !important;
111
+ display: flex !important;
112
+ flex-direction: column !important;
113
+ align-items: flex-start !important;
114
+ }
115
+
116
+ .visns-select__multi-value__label {
117
+ padding: 0 !important;
118
+ display: flex !important;
119
+ flex-direction: column !important;
120
+ width: 100% !important;
121
+ }
122
+
123
+ .visns-select__multi-value__remove {
124
+ padding: 0 4px !important;
125
+ align-self: flex-start !important;
126
+ margin-top: 2px !important;
68
127
  }
69
128
 
70
129
  /* Fix for modals */
@@ -84,6 +143,11 @@
84
143
  overflow: visible !important;
85
144
  }
86
145
 
146
+ /* Option styling for description */
147
+ .visns-select__option {
148
+ padding: 8px 12px !important;
149
+ }
150
+
87
151
  .css-1fdsijx-ValueContainer {
88
152
  padding: 2px 8px !important;
89
153
  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
+ };