@visns-studio/visns-components 5.11.5 → 5.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +353 -0
- package/package.json +5 -5
- package/src/components/crm/Field.jsx +88 -60
- package/src/components/crm/columns/ColumnRenderers.jsx +19 -0
- package/src/components/crm/generic/GenericDetail.jsx +40 -0
- package/src/components/crm/generic/GenericEditableTable.jsx +3 -3
- package/src/components/crm/generic/GenericQuote.jsx +353 -1
- package/src/components/crm/generic/styles/GenericEditableTable.module.scss +102 -22
- package/src/components/crm/generic/styles/GenericQuote.module.scss +240 -0
- package/src/components/proposal/ProposalTemplatePreview.css +438 -0
- package/src/components/proposal/ProposalTemplatePreview.jsx +337 -0
- package/src/components/proposal/ProposalTemplateSectionManager.css +338 -0
- package/src/components/proposal/ProposalTemplateSectionManager.jsx +473 -0
- package/src/components/proposal/SectionEditor.css +563 -0
- package/src/components/proposal/SectionEditor.jsx +412 -0
- package/src/components/proposal/SectionTypeSelector.css +277 -0
- package/src/components/proposal/SectionTypeSelector.jsx +158 -0
- package/src/components/proposal/VariableInserter.css +392 -0
- package/src/components/proposal/VariableInserter.jsx +260 -0
- package/src/components/proposal/index.js +5 -0
- package/src/components/styles/global.css +179 -1
- package/src/index.js +13 -0
- package/src/utils/urlBuilder.js +40 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Plus, Move, Eye, EyeOff, Trash2 } from 'lucide-react';
|
|
3
|
+
import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc';
|
|
4
|
+
import { arrayMoveImmutable } from 'array-move';
|
|
5
|
+
import SectionEditor from './SectionEditor';
|
|
6
|
+
import SectionTypeSelector from './SectionTypeSelector';
|
|
7
|
+
import { toast } from 'react-toastify';
|
|
8
|
+
import { buildUrl, defaultProposalApiEndpoints } from '../../utils/urlBuilder';
|
|
9
|
+
import './ProposalTemplateSectionManager.css';
|
|
10
|
+
|
|
11
|
+
const DragHandle = SortableHandle(() => (
|
|
12
|
+
<div className="drag-handle">
|
|
13
|
+
<Move size={16} />
|
|
14
|
+
</div>
|
|
15
|
+
));
|
|
16
|
+
|
|
17
|
+
const SortableSection = SortableElement(({ section, onEdit, onDelete, onToggleVisibility }) => (
|
|
18
|
+
<div className="section-item">
|
|
19
|
+
<div className="section-header">
|
|
20
|
+
<div className="section-info">
|
|
21
|
+
<DragHandle />
|
|
22
|
+
<div className="flex-1">
|
|
23
|
+
<div className="flex items-center space-x-2">
|
|
24
|
+
<h4 className="section-title">{section.title}</h4>
|
|
25
|
+
<span className="section-type-badge">
|
|
26
|
+
{section.section_type?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
|
27
|
+
</span>
|
|
28
|
+
{section.is_dynamic && (
|
|
29
|
+
<span className="section-dynamic-badge">
|
|
30
|
+
Dynamic
|
|
31
|
+
</span>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
<p className="section-meta">
|
|
35
|
+
Order: {section.sort_order} | Variables: {section.variables?.length || 0}
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div className="section-actions">
|
|
41
|
+
<button
|
|
42
|
+
onClick={() => onToggleVisibility(section.id)}
|
|
43
|
+
className="section-action-btn"
|
|
44
|
+
title={section.is_enabled ? "Hide section" : "Show section"}
|
|
45
|
+
>
|
|
46
|
+
{section.is_enabled ? <Eye size={16} /> : <EyeOff size={16} />}
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
<button
|
|
51
|
+
onClick={(e) => {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
e.stopPropagation();
|
|
54
|
+
onEdit(section);
|
|
55
|
+
}}
|
|
56
|
+
className="section-action-btn edit"
|
|
57
|
+
>
|
|
58
|
+
Edit
|
|
59
|
+
</button>
|
|
60
|
+
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => onDelete(section.id)}
|
|
63
|
+
className="section-action-btn delete"
|
|
64
|
+
title="Delete section"
|
|
65
|
+
>
|
|
66
|
+
<Trash2 size={16} />
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="section-content">
|
|
72
|
+
<div className="section-content-preview">
|
|
73
|
+
{section.content && section.content.trim() ? (
|
|
74
|
+
<div
|
|
75
|
+
dangerouslySetInnerHTML={{
|
|
76
|
+
__html: section.content.substring(0, 200) + (section.content.length > 200 ? '...' : '')
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
) : (
|
|
80
|
+
<p className="section-no-content">
|
|
81
|
+
No content added yet
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
));
|
|
88
|
+
|
|
89
|
+
const SortableSectionList = SortableContainer(({ sections, onEdit, onDelete, onToggleVisibility }) => (
|
|
90
|
+
<div>
|
|
91
|
+
{sections.map((section, index) => (
|
|
92
|
+
<SortableSection
|
|
93
|
+
key={`section-${section.id || index}`}
|
|
94
|
+
index={index}
|
|
95
|
+
section={section}
|
|
96
|
+
onEdit={onEdit}
|
|
97
|
+
onDelete={onDelete}
|
|
98
|
+
onToggleVisibility={onToggleVisibility}
|
|
99
|
+
/>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
));
|
|
103
|
+
|
|
104
|
+
const ProposalTemplateSectionManager = ({
|
|
105
|
+
templateId,
|
|
106
|
+
templateData,
|
|
107
|
+
value = [],
|
|
108
|
+
onChange,
|
|
109
|
+
apiEndpoints = defaultProposalApiEndpoints
|
|
110
|
+
}) => {
|
|
111
|
+
const [sections, setSections] = useState([]);
|
|
112
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
113
|
+
const [editingSection, setEditingSection] = useState(null);
|
|
114
|
+
const [showSectionTypeSelector, setShowSectionTypeSelector] = useState(false);
|
|
115
|
+
const [availableVariables, setAvailableVariables] = useState([]);
|
|
116
|
+
|
|
117
|
+
// Helper function to replace template variables in URLs with templateId included
|
|
118
|
+
const buildUrlWithTemplate = (urlTemplate, params = {}) => {
|
|
119
|
+
return buildUrl(urlTemplate, { templateId, ...params });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (templateData && templateData.sections) {
|
|
124
|
+
setSections(templateData.sections);
|
|
125
|
+
setIsLoading(false);
|
|
126
|
+
} else if (templateId) {
|
|
127
|
+
loadSections();
|
|
128
|
+
}
|
|
129
|
+
loadAvailableVariables();
|
|
130
|
+
}, [templateId, templateData]);
|
|
131
|
+
|
|
132
|
+
const loadSections = async () => {
|
|
133
|
+
try {
|
|
134
|
+
setIsLoading(true);
|
|
135
|
+
const response = await fetch(buildUrlWithTemplate(apiEndpoints.templateData));
|
|
136
|
+
const data = await response.json();
|
|
137
|
+
setSections(data.sections || []);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('Error loading sections:', error);
|
|
140
|
+
toast.error('Failed to load template sections');
|
|
141
|
+
} finally {
|
|
142
|
+
setIsLoading(false);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const loadAvailableVariables = async () => {
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetch(apiEndpoints.availableVariables);
|
|
149
|
+
const data = await response.json();
|
|
150
|
+
setAvailableVariables(data.variables || []);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('Error loading variables:', error);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const handleSortEnd = async ({ oldIndex, newIndex }) => {
|
|
157
|
+
if (oldIndex === newIndex) return;
|
|
158
|
+
|
|
159
|
+
const newSections = arrayMoveImmutable(sections, oldIndex, newIndex);
|
|
160
|
+
|
|
161
|
+
// Update sort_order for all sections
|
|
162
|
+
const updatedSections = newSections.map((section, index) => ({
|
|
163
|
+
...section,
|
|
164
|
+
sort_order: index + 1
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
setSections(updatedSections);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// Update sort orders on the backend
|
|
171
|
+
await fetch(buildUrlWithTemplate(apiEndpoints.sectionsReorder), {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: {
|
|
174
|
+
'Content-Type': 'application/json',
|
|
175
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
sections: updatedSections.map(s => ({ id: s.id, sort_order: s.sort_order }))
|
|
179
|
+
})
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await loadSections(); // Refetch fresh data from server
|
|
183
|
+
toast.success('Section order updated');
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error('Error updating section order:', error);
|
|
186
|
+
toast.error('Failed to update section order');
|
|
187
|
+
// Revert on error
|
|
188
|
+
loadSections();
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const handleAddSection = async (sectionType) => {
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch(buildUrlWithTemplate(apiEndpoints.sectionsCreate), {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: {
|
|
197
|
+
'Content-Type': 'application/json',
|
|
198
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
section_type: sectionType,
|
|
202
|
+
title: sectionType.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
|
203
|
+
content: '',
|
|
204
|
+
sort_order: sections.length + 1,
|
|
205
|
+
is_enabled: true,
|
|
206
|
+
is_dynamic: true
|
|
207
|
+
})
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (response.ok) {
|
|
211
|
+
await loadSections(); // Refetch fresh data from server
|
|
212
|
+
setShowSectionTypeSelector(false);
|
|
213
|
+
toast.success('New section added');
|
|
214
|
+
} else {
|
|
215
|
+
throw new Error('Failed to create section');
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error('Error adding section:', error);
|
|
219
|
+
toast.error('Failed to add new section');
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const handleEditSection = (section) => {
|
|
224
|
+
setEditingSection(section);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const handleSaveSection = async (sectionData) => {
|
|
228
|
+
try {
|
|
229
|
+
// Helper function to deeply clean objects and remove circular references
|
|
230
|
+
const cleanObject = (obj, maxDepth = 3, currentDepth = 0) => {
|
|
231
|
+
if (currentDepth >= maxDepth || obj === null || typeof obj !== 'object') {
|
|
232
|
+
return obj;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (Array.isArray(obj)) {
|
|
236
|
+
return obj.map(item => cleanObject(item, maxDepth, currentDepth + 1));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const cleaned = {};
|
|
240
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
241
|
+
// Skip functions, DOM elements, and potential circular references
|
|
242
|
+
if (typeof value === 'function' ||
|
|
243
|
+
value instanceof Element ||
|
|
244
|
+
value instanceof HTMLElement ||
|
|
245
|
+
key.startsWith('_') ||
|
|
246
|
+
key === 'target' ||
|
|
247
|
+
key === 'currentTarget') {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
cleaned[key] = cleanObject(value, maxDepth, currentDepth + 1);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
// Skip properties that cause issues
|
|
255
|
+
console.warn(`Skipping property ${key} due to error:`, e.message);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return cleaned;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Helper function to clean content specifically
|
|
262
|
+
const cleanContent = (content) => {
|
|
263
|
+
if (content === null || content === undefined) {
|
|
264
|
+
return '';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (typeof content === 'string') {
|
|
268
|
+
return content;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// If content is an object, try to extract string value
|
|
272
|
+
if (typeof content === 'object') {
|
|
273
|
+
console.log('Content is object with keys:', Object.keys(content));
|
|
274
|
+
// Check for common TinyMCE/editor object patterns
|
|
275
|
+
if (content.value && typeof content.value === 'string') {
|
|
276
|
+
return content.value;
|
|
277
|
+
}
|
|
278
|
+
if (content.content && typeof content.content === 'string') {
|
|
279
|
+
return content.content;
|
|
280
|
+
}
|
|
281
|
+
if (content.text && typeof content.text === 'string') {
|
|
282
|
+
return content.text;
|
|
283
|
+
}
|
|
284
|
+
if (content.innerHTML && typeof content.innerHTML === 'string') {
|
|
285
|
+
return content.innerHTML;
|
|
286
|
+
}
|
|
287
|
+
// If it's an object but can't find a string property, convert to string
|
|
288
|
+
try {
|
|
289
|
+
// Try to convert object to string, but avoid circular references
|
|
290
|
+
return JSON.stringify(content);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.warn('Content object could not be serialized, using empty string');
|
|
293
|
+
return '';
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// For any other type, convert to string
|
|
298
|
+
return String(content);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Create a clean object without circular references
|
|
302
|
+
const cleanSectionData = {
|
|
303
|
+
id: sectionData.id,
|
|
304
|
+
title: sectionData.title || '',
|
|
305
|
+
section_type: sectionData.section_type || sectionData.type,
|
|
306
|
+
content: cleanContent(sectionData.content),
|
|
307
|
+
is_enabled: Boolean(sectionData.is_enabled),
|
|
308
|
+
is_dynamic: Boolean(sectionData.is_dynamic),
|
|
309
|
+
sort_order: Number(sectionData.sort_order || sectionData.order || 0),
|
|
310
|
+
styling: cleanObject(sectionData.styling) || {},
|
|
311
|
+
variables: cleanObject(sectionData.variables) || []
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Test JSON serialization before sending
|
|
315
|
+
let jsonPayload;
|
|
316
|
+
try {
|
|
317
|
+
jsonPayload = JSON.stringify(cleanSectionData);
|
|
318
|
+
} catch (jsonError) {
|
|
319
|
+
console.error('Failed to serialize cleaned section data:', jsonError);
|
|
320
|
+
console.error('Problematic data:', cleanSectionData);
|
|
321
|
+
throw new Error('Unable to serialize section data');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const response = await fetch(buildUrlWithTemplate(apiEndpoints.sectionsUpdate, { sectionId: sectionData.id }), {
|
|
325
|
+
method: 'PUT',
|
|
326
|
+
headers: {
|
|
327
|
+
'Content-Type': 'application/json',
|
|
328
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
329
|
+
},
|
|
330
|
+
body: jsonPayload
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (response.ok) {
|
|
334
|
+
await loadSections(); // Refetch fresh data from server
|
|
335
|
+
setEditingSection(null);
|
|
336
|
+
toast.success('Section updated');
|
|
337
|
+
} else {
|
|
338
|
+
throw new Error('Failed to update section');
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
console.error('Error saving section:', error);
|
|
342
|
+
console.error('Section data keys:', Object.keys(sectionData));
|
|
343
|
+
toast.error('Failed to save section');
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const handleDeleteSection = async (sectionId) => {
|
|
348
|
+
if (!confirm('Are you sure you want to delete this section?')) return;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const response = await fetch(buildUrlWithTemplate(apiEndpoints.sectionsDelete, { sectionId }), {
|
|
352
|
+
method: 'DELETE',
|
|
353
|
+
headers: {
|
|
354
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (response.ok) {
|
|
359
|
+
await loadSections(); // Refetch fresh data from server
|
|
360
|
+
toast.success('Section deleted');
|
|
361
|
+
} else {
|
|
362
|
+
throw new Error('Failed to delete section');
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.error('Error deleting section:', error);
|
|
366
|
+
toast.error('Failed to delete section');
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const handleToggleVisibility = async (sectionId) => {
|
|
371
|
+
const section = sections.find(s => s.id === sectionId);
|
|
372
|
+
if (!section) return;
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const response = await fetch(buildUrlWithTemplate(apiEndpoints.sectionsToggle, { sectionId }), {
|
|
376
|
+
method: 'PUT',
|
|
377
|
+
headers: {
|
|
378
|
+
'Content-Type': 'application/json',
|
|
379
|
+
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
380
|
+
},
|
|
381
|
+
body: JSON.stringify({
|
|
382
|
+
...section,
|
|
383
|
+
is_enabled: !section.is_enabled
|
|
384
|
+
})
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
if (response.ok) {
|
|
388
|
+
const updatedSection = await response.json();
|
|
389
|
+
await loadSections(); // Refetch fresh data from server
|
|
390
|
+
toast.success(`Section ${updatedSection.is_enabled ? 'enabled' : 'disabled'}`);
|
|
391
|
+
} else {
|
|
392
|
+
throw new Error('Failed to toggle section visibility');
|
|
393
|
+
}
|
|
394
|
+
} catch (error) {
|
|
395
|
+
console.error('Error toggling section visibility:', error);
|
|
396
|
+
toast.error('Failed to update section visibility');
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if (isLoading) {
|
|
402
|
+
return (
|
|
403
|
+
<div className="loading-spinner">
|
|
404
|
+
<div className="spinner"></div>
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<div className="proposal-section-manager">
|
|
411
|
+
<div className="add-section-header">
|
|
412
|
+
<div>
|
|
413
|
+
<h3 className="add-section-title">Template Sections</h3>
|
|
414
|
+
<p className="add-section-description">
|
|
415
|
+
Drag and drop to reorder sections. Use variables like {`{{customer_name}}`} for dynamic content.
|
|
416
|
+
</p>
|
|
417
|
+
</div>
|
|
418
|
+
<button
|
|
419
|
+
onClick={() => setShowSectionTypeSelector(true)}
|
|
420
|
+
className="add-section-btn"
|
|
421
|
+
>
|
|
422
|
+
<Plus className="w-4 h-4" />
|
|
423
|
+
Add Section
|
|
424
|
+
</button>
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
{sections.length === 0 ? (
|
|
428
|
+
<div className="empty-state">
|
|
429
|
+
<div className="empty-state-icon">
|
|
430
|
+
<Plus size={48} />
|
|
431
|
+
</div>
|
|
432
|
+
<h3 className="empty-state-title">No sections yet</h3>
|
|
433
|
+
<p className="empty-state-description">Get started by adding your first template section</p>
|
|
434
|
+
<button
|
|
435
|
+
onClick={() => setShowSectionTypeSelector(true)}
|
|
436
|
+
className="empty-state-btn"
|
|
437
|
+
>
|
|
438
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
439
|
+
Add First Section
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
) : (
|
|
443
|
+
<SortableSectionList
|
|
444
|
+
sections={sections}
|
|
445
|
+
onSortEnd={handleSortEnd}
|
|
446
|
+
onEdit={handleEditSection}
|
|
447
|
+
onDelete={handleDeleteSection}
|
|
448
|
+
onToggleVisibility={handleToggleVisibility}
|
|
449
|
+
useDragHandle
|
|
450
|
+
/>
|
|
451
|
+
)}
|
|
452
|
+
|
|
453
|
+
{showSectionTypeSelector && (
|
|
454
|
+
<SectionTypeSelector
|
|
455
|
+
onSelect={handleAddSection}
|
|
456
|
+
onCancel={() => setShowSectionTypeSelector(false)}
|
|
457
|
+
/>
|
|
458
|
+
)}
|
|
459
|
+
|
|
460
|
+
{editingSection && (
|
|
461
|
+
<SectionEditor
|
|
462
|
+
section={editingSection}
|
|
463
|
+
availableVariables={availableVariables}
|
|
464
|
+
onSave={handleSaveSection}
|
|
465
|
+
onCancel={() => setEditingSection(null)}
|
|
466
|
+
/>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
</div>
|
|
470
|
+
);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
export default ProposalTemplateSectionManager;
|