@visns-studio/visns-components 5.7.11 → 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 +1 -1
- package/src/components/crm/MultiSelect.jsx +29 -3
- package/src/components/crm/SelectList.jsx +4 -2
- package/src/components/crm/generic/GenericQuote.jsx +335 -71
- package/src/components/crm/generic/SortableQuoteItems.jsx +87 -0
- package/src/components/crm/generic/styles/GenericQuote.module.scss +56 -1
- package/src/components/crm/generic/styles/SortableQuoteItems.module.scss +101 -0
- package/src/components/crm/styles/MultiSelect.module.scss +24 -0
- package/src/utils/ConfirmDialog.js +67 -0
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.
|
|
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={{
|
|
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' :
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
536
|
-
{
|
|
537
|
-
|
|
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
|
-
<
|
|
563
|
-
{data[table.id]
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
</
|
|
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:
|
|
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,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
|
+
};
|