@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.
@@ -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;