@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.
@@ -74,6 +74,10 @@ import QrCode from '../QrCode';
74
74
  import Table from '../DataGrid';
75
75
  import TableFilter from '../TableFilter';
76
76
 
77
+ // Proposal components
78
+ import ProposalTemplateSectionManager from '../../proposal/ProposalTemplateSectionManager';
79
+ import ProposalTemplatePreview from '../../proposal/ProposalTemplatePreview';
80
+
77
81
  import styles from './styles/GenericDetail.module.scss';
78
82
 
79
83
  const localizer = momentLocalizer(moment);
@@ -2514,7 +2518,43 @@ function GenericDetail({
2514
2518
  />
2515
2519
  );
2516
2520
  }
2521
+ if (activeTabConfig.component === 'ProposalTemplateSectionManager') {
2522
+ return (
2523
+ <ProposalTemplateSectionManager
2524
+ {...activeTabConfig.props}
2525
+ templateId={routeParams[urlParam]}
2526
+ onUpdate={handleReload}
2527
+ />
2528
+ );
2529
+ }
2530
+ if (activeTabConfig.component === 'ProposalTemplatePreview') {
2531
+ return (
2532
+ <ProposalTemplatePreview
2533
+ {...activeTabConfig.props}
2534
+ templateId={routeParams[urlParam]}
2535
+ onUpdate={handleReload}
2536
+ />
2537
+ );
2538
+ }
2517
2539
  return null;
2540
+ case 'proposalSectionManager':
2541
+ return (
2542
+ <ProposalTemplateSectionManager
2543
+ {...activeTabConfig.props}
2544
+ templateId={routeParams[urlParam]}
2545
+ templateData={data}
2546
+ onUpdate={handleReload}
2547
+ />
2548
+ );
2549
+ case 'proposalPreview':
2550
+ return (
2551
+ <ProposalTemplatePreview
2552
+ {...activeTabConfig.props}
2553
+ templateId={routeParams[urlParam]}
2554
+ templateData={data}
2555
+ onUpdate={handleReload}
2556
+ />
2557
+ );
2518
2558
  default:
2519
2559
  return null;
2520
2560
  }
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom';
3
3
  import debounce from 'lodash.debounce';
4
4
  import { toast } from 'react-toastify';
5
5
  import { Trash2, ArrowUp, ArrowDown, Copy, Droplets } from 'lucide-react';
6
- import { CompactPicker } from 'react-color';
6
+ import { Compact } from '@uiw/react-color';
7
7
  import { confirmDialog } from '../../utils/ConfirmDialog';
8
8
 
9
9
  import CustomFetch from '../Fetch';
@@ -85,10 +85,10 @@ const ColourPicker = ({ value, columnId, keyCounter, onChange }) => {
85
85
  onClick={() => setColorPickerShow(false)}
86
86
  />
87
87
  <div className={styles.pickerContainer}>
88
- <CompactPicker
88
+ <Compact
89
89
  color={value || ''}
90
90
  colors={pastelColors}
91
- onChangeComplete={(color) => {
91
+ onChange={(color) => {
92
92
  onChange(
93
93
  color.hex,
94
94
  columnId,
@@ -9,6 +9,7 @@ import { saveAs } from 'file-saver';
9
9
  import { toast } from 'react-toastify';
10
10
  import { arrayMoveImmutable } from 'array-move';
11
11
  import { showConfirmDialog } from './ConfirmationDialog';
12
+ import { FileText, DollarSign, Clock, Eye, X } from 'lucide-react';
12
13
 
13
14
  import CustomFetch from '../../crm/Fetch';
14
15
  import ActionButtons from './ActionButtons';
@@ -18,7 +19,7 @@ import SortableTableBody from './SortableQuoteItems';
18
19
 
19
20
  import styles from './styles/GenericQuote.module.scss';
20
21
 
21
- function GenericQuote({ logo, setting, urlParam }) {
22
+ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
22
23
  const routeParams = useParams();
23
24
  const navigate = useNavigate();
24
25
 
@@ -26,6 +27,15 @@ function GenericQuote({ logo, setting, urlParam }) {
26
27
 
27
28
  const [data, setData] = useState({});
28
29
  const [actions, setActions] = useState(actionItems || []);
30
+
31
+ // Proposal system state (backward compatible)
32
+ const [proposalMode, setProposalMode] = useState(false);
33
+ const [proposalTemplates, setProposalTemplates] = useState([]);
34
+ const [brandingProfiles, setBrandingProfiles] = useState([]);
35
+ const [selectedTemplate, setSelectedTemplate] = useState(null);
36
+ const [selectedBranding, setSelectedBranding] = useState(null);
37
+ const [proposalPreview, setProposalPreview] = useState(null);
38
+ const [isGeneratingProposal, setIsGeneratingProposal] = useState(false);
29
39
 
30
40
  // Modal state variables
31
41
  const [modalShow, setModalShow] = useState(false);
@@ -489,6 +499,224 @@ function GenericQuote({ logo, setting, urlParam }) {
489
499
  });
490
500
  };
491
501
 
502
+ // Proposal system functions (backward compatible)
503
+ const loadProposalData = async () => {
504
+ if (!proposalConfig) return;
505
+
506
+ try {
507
+ // Load proposal templates
508
+ if (proposalConfig.templateEndpoint) {
509
+ const templatesRes = await CustomFetch(
510
+ `${proposalConfig.templateEndpoint}/dropdown`,
511
+ 'POST',
512
+ {}
513
+ );
514
+ if (templatesRes.success) {
515
+ setProposalTemplates(templatesRes.data);
516
+
517
+ // Set default template if available
518
+ const defaultTemplate = templatesRes.data.find(
519
+ t => t.name?.includes('Standard') || t.name?.includes('Default')
520
+ );
521
+ if (defaultTemplate) {
522
+ setSelectedTemplate(defaultTemplate.id);
523
+ }
524
+ }
525
+ }
526
+
527
+ // Load branding profiles
528
+ if (proposalConfig.brandingEndpoint) {
529
+ const brandingRes = await CustomFetch(
530
+ `${proposalConfig.brandingEndpoint}/dropdown`,
531
+ 'POST',
532
+ {}
533
+ );
534
+ if (brandingRes.success) {
535
+ setBrandingProfiles(brandingRes.data);
536
+
537
+ // Load default branding
538
+ const defaultBrandingRes = await CustomFetch(
539
+ `${proposalConfig.brandingEndpoint}/default`,
540
+ 'GET',
541
+ {}
542
+ );
543
+ if (defaultBrandingRes.success) {
544
+ setSelectedBranding(defaultBrandingRes.data.id);
545
+ }
546
+ }
547
+ }
548
+ } catch (error) {
549
+ console.error('Error loading proposal data:', error);
550
+ toast.error('Failed to load proposal templates and branding');
551
+ }
552
+ };
553
+
554
+ const generateProposalPreview = async () => {
555
+ if (!selectedTemplate || !proposalConfig?.previewEndpoint) return;
556
+
557
+ setIsGeneratingProposal(true);
558
+ const toastId = toast.loading('Generating proposal preview...');
559
+
560
+ try {
561
+ const proposalData = {
562
+ proposal_data: {
563
+ customer_name: data.customer?.name || 'Customer Name',
564
+ customer_address: data.customer?.sites?.[0] ?
565
+ `${data.customer.sites[0].address1 || ''} ${data.customer.sites[0].address2 || ''}, ${data.customer.sites[0].suburb || ''} ${data.customer.sites[0].state || ''} ${data.customer.sites[0].postcode || ''}`.trim() :
566
+ 'Customer Address',
567
+ quote_number: data.id || 'Q-001',
568
+ quote_date: data.created_at || new Date().toISOString().split('T')[0],
569
+ current_date: new Date().toISOString().split('T')[0],
570
+ total_amount: formatCurrency(calculateGrandTotal()),
571
+ company_name: 'VISNS Studio',
572
+ terms_and_conditions: data.terms_and_condition || 'Standard terms and conditions apply.',
573
+ items_onceoff: data.items_onceoff || [],
574
+ items_monthly_subscription: data.items_monthly_subscription || [],
575
+ items_yearly_subscription: data.items_yearly_subscription || [],
576
+ },
577
+ template_id: selectedTemplate,
578
+ branding_id: selectedBranding,
579
+ };
580
+
581
+ const response = await CustomFetch(
582
+ proposalConfig.previewEndpoint,
583
+ 'POST',
584
+ proposalData
585
+ );
586
+
587
+ if (response.success) {
588
+ setProposalPreview(response.html);
589
+ toast.update(toastId, {
590
+ render: 'Proposal preview generated successfully',
591
+ type: 'success',
592
+ isLoading: false,
593
+ autoClose: 3000,
594
+ closeButton: true,
595
+ });
596
+ } else {
597
+ throw new Error(response.message || 'Failed to generate preview');
598
+ }
599
+ } catch (error) {
600
+ console.error('Error generating proposal preview:', error);
601
+ toast.update(toastId, {
602
+ render: 'Failed to generate proposal preview: ' + error.message,
603
+ type: 'error',
604
+ isLoading: false,
605
+ autoClose: 5000,
606
+ closeButton: true,
607
+ });
608
+ } finally {
609
+ setIsGeneratingProposal(false);
610
+ }
611
+ };
612
+
613
+ const generateProposalPDF = async () => {
614
+ if (!selectedTemplate || !proposalConfig?.pdfEndpoint) return;
615
+
616
+ setIsGeneratingProposal(true);
617
+ const toastId = toast.loading('Generating proposal PDF...');
618
+
619
+ try {
620
+ const proposalData = {
621
+ proposal_data: {
622
+ customer_name: data.customer?.name || 'Customer Name',
623
+ customer_address: data.customer?.sites?.[0] ?
624
+ `${data.customer.sites[0].address1 || ''} ${data.customer.sites[0].address2 || ''}, ${data.customer.sites[0].suburb || ''} ${data.customer.sites[0].state || ''} ${data.customer.sites[0].postcode || ''}`.trim() :
625
+ 'Customer Address',
626
+ quote_number: data.id || 'Q-001',
627
+ quote_date: data.created_at || new Date().toISOString().split('T')[0],
628
+ current_date: new Date().toISOString().split('T')[0],
629
+ total_amount: formatCurrency(calculateGrandTotal()),
630
+ company_name: 'VISNS Studio',
631
+ terms_and_conditions: data.terms_and_condition || 'Standard terms and conditions apply.',
632
+ items_onceoff: data.items_onceoff || [],
633
+ items_monthly_subscription: data.items_monthly_subscription || [],
634
+ items_yearly_subscription: data.items_yearly_subscription || [],
635
+ },
636
+ template_id: selectedTemplate,
637
+ branding_id: selectedBranding,
638
+ filename: `proposal-${data.id || 'draft'}-${new Date().toISOString().split('T')[0]}.pdf`,
639
+ download: true,
640
+ };
641
+
642
+ // Create a form for file download
643
+ const form = document.createElement('form');
644
+ form.method = 'POST';
645
+ form.action = proposalConfig.pdfEndpoint;
646
+ form.style.display = 'none';
647
+
648
+ // Add CSRF token
649
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
650
+ if (csrfToken) {
651
+ const csrfInput = document.createElement('input');
652
+ csrfInput.type = 'hidden';
653
+ csrfInput.name = '_token';
654
+ csrfInput.value = csrfToken;
655
+ form.appendChild(csrfInput);
656
+ }
657
+
658
+ // Add proposal data as hidden input
659
+ const dataInput = document.createElement('input');
660
+ dataInput.type = 'hidden';
661
+ dataInput.name = 'proposal_data';
662
+ dataInput.value = JSON.stringify(proposalData.proposal_data);
663
+ form.appendChild(dataInput);
664
+
665
+ const templateInput = document.createElement('input');
666
+ templateInput.type = 'hidden';
667
+ templateInput.name = 'template_id';
668
+ templateInput.value = selectedTemplate;
669
+ form.appendChild(templateInput);
670
+
671
+ const brandingInput = document.createElement('input');
672
+ brandingInput.type = 'hidden';
673
+ brandingInput.name = 'branding_id';
674
+ brandingInput.value = selectedBranding;
675
+ form.appendChild(brandingInput);
676
+
677
+ const filenameInput = document.createElement('input');
678
+ filenameInput.type = 'hidden';
679
+ filenameInput.name = 'filename';
680
+ filenameInput.value = proposalData.filename;
681
+ form.appendChild(filenameInput);
682
+
683
+ // Submit form to trigger download
684
+ document.body.appendChild(form);
685
+ form.submit();
686
+ document.body.removeChild(form);
687
+
688
+ toast.update(toastId, {
689
+ render: 'Proposal PDF download started',
690
+ type: 'success',
691
+ isLoading: false,
692
+ autoClose: 3000,
693
+ closeButton: true,
694
+ });
695
+ } catch (error) {
696
+ console.error('Error generating proposal PDF:', error);
697
+ toast.update(toastId, {
698
+ render: 'Failed to generate proposal PDF: ' + error.message,
699
+ type: 'error',
700
+ isLoading: false,
701
+ autoClose: 5000,
702
+ closeButton: true,
703
+ });
704
+ } finally {
705
+ setIsGeneratingProposal(false);
706
+ }
707
+ };
708
+
709
+ const toggleProposalMode = () => {
710
+ const newMode = !proposalMode;
711
+ setProposalMode(newMode);
712
+
713
+ if (newMode && proposalTemplates.length === 0) {
714
+ loadProposalData();
715
+ }
716
+
717
+ toast.info(newMode ? 'Switched to Proposal Mode' : 'Switched to Quote Mode');
718
+ };
719
+
492
720
  const handleAddItem = (tableId) => {
493
721
  console.log('Add item to table:', tableId);
494
722
 
@@ -860,6 +1088,130 @@ function GenericQuote({ logo, setting, urlParam }) {
860
1088
  </div>
861
1089
  </div>
862
1090
 
1091
+ {/* Proposal Mode Controls (backward compatible) */}
1092
+ {proposalConfig && (
1093
+ <div className={styles.proposalControls}>
1094
+ <div className={styles.proposalToggle}>
1095
+ <button
1096
+ className={`${styles.modeToggle} ${proposalMode ? styles.proposalMode : styles.quoteMode}`}
1097
+ onClick={toggleProposalMode}
1098
+ disabled={isGeneratingProposal}
1099
+ >
1100
+ {proposalMode ? (
1101
+ <>
1102
+ <FileText size={16} aria-hidden="true" />
1103
+ <span>Proposal Mode</span>
1104
+ </>
1105
+ ) : (
1106
+ <>
1107
+ <DollarSign size={16} aria-hidden="true" />
1108
+ <span>Quote Mode</span>
1109
+ </>
1110
+ )}
1111
+ </button>
1112
+ </div>
1113
+
1114
+ {proposalMode && (
1115
+ <div className={styles.proposalSettings}>
1116
+ <div className={styles.proposalSelectors}>
1117
+ <div className={styles.selectorGroup}>
1118
+ <label htmlFor="templateSelect">Template:</label>
1119
+ <select
1120
+ id="templateSelect"
1121
+ value={selectedTemplate || ''}
1122
+ onChange={(e) => setSelectedTemplate(e.target.value)}
1123
+ className={styles.proposalSelect}
1124
+ disabled={isGeneratingProposal}
1125
+ >
1126
+ <option value="">Select Template</option>
1127
+ {proposalTemplates.map((template) => (
1128
+ <option key={template.id} value={template.id}>
1129
+ {template.name}
1130
+ </option>
1131
+ ))}
1132
+ </select>
1133
+ </div>
1134
+
1135
+ <div className={styles.selectorGroup}>
1136
+ <label htmlFor="brandingSelect">Branding:</label>
1137
+ <select
1138
+ id="brandingSelect"
1139
+ value={selectedBranding || ''}
1140
+ onChange={(e) => setSelectedBranding(e.target.value)}
1141
+ className={styles.proposalSelect}
1142
+ disabled={isGeneratingProposal}
1143
+ >
1144
+ <option value="">Select Branding</option>
1145
+ {brandingProfiles.map((profile) => (
1146
+ <option key={profile.id} value={profile.id}>
1147
+ {profile.name}
1148
+ </option>
1149
+ ))}
1150
+ </select>
1151
+ </div>
1152
+
1153
+ <div className={styles.proposalActions}>
1154
+ <button
1155
+ className={styles.previewButton}
1156
+ onClick={generateProposalPreview}
1157
+ disabled={!selectedTemplate || isGeneratingProposal}
1158
+ >
1159
+ {isGeneratingProposal ? (
1160
+ <>
1161
+ <Clock size={16} aria-hidden="true" />
1162
+ <span>Generating...</span>
1163
+ </>
1164
+ ) : (
1165
+ <>
1166
+ <Eye size={16} aria-hidden="true" />
1167
+ <span>Preview</span>
1168
+ </>
1169
+ )}
1170
+ </button>
1171
+ <button
1172
+ className={styles.pdfButton}
1173
+ onClick={generateProposalPDF}
1174
+ disabled={!selectedTemplate || isGeneratingProposal}
1175
+ >
1176
+ {isGeneratingProposal ? (
1177
+ <>
1178
+ <Clock size={16} aria-hidden="true" />
1179
+ <span>Generating...</span>
1180
+ </>
1181
+ ) : (
1182
+ <>
1183
+ <FileText size={16} aria-hidden="true" />
1184
+ <span>Generate PDF</span>
1185
+ </>
1186
+ )}
1187
+ </button>
1188
+ </div>
1189
+ </div>
1190
+ </div>
1191
+ )}
1192
+ </div>
1193
+ )}
1194
+
1195
+ {/* Proposal Preview (if available) */}
1196
+ {proposalMode && proposalPreview && (
1197
+ <div className={styles.proposalPreviewContainer}>
1198
+ <div className={styles.proposalPreviewHeader}>
1199
+ <h3>Proposal Preview</h3>
1200
+ <button
1201
+ className={styles.closePreview}
1202
+ onClick={() => setProposalPreview(null)}
1203
+ aria-label="Close preview"
1204
+ >
1205
+ <X size={16} aria-hidden="true" />
1206
+ </button>
1207
+ </div>
1208
+ <div
1209
+ className={styles.proposalPreview}
1210
+ dangerouslySetInnerHTML={{ __html: proposalPreview }}
1211
+ />
1212
+ </div>
1213
+ )}
1214
+
863
1215
  {/* Quote content */}
864
1216
  <div className={styles.quoteContent}>
865
1217
  {/* Quote header with logo and date */}
@@ -245,36 +245,116 @@
245
245
  }
246
246
  }
247
247
 
248
- .cpicker {
249
- position: relative;
248
+ /* Compact Inline Color Picker Styles */
249
+ .cpicker-inline {
250
+ width: 100%;
251
+ display: flex;
252
+ flex-direction: column;
253
+ gap: 8px;
254
+ }
255
+
256
+ .cpicker-preview-container {
250
257
  display: flex;
251
258
  align-items: center;
259
+ gap: 8px;
260
+ padding: 6px 10px;
261
+ background: #f8f9fa;
262
+ border: 1px solid #dee2e6;
263
+ border-radius: 6px;
264
+ min-height: 40px;
265
+ }
252
266
 
253
- &__btn {
254
- button {
255
- width: 28px;
256
- height: 28px;
257
- background-color: #f0f0f0;
258
- border: 1px solid #ccc;
259
- border-radius: 6px;
260
- display: flex;
261
- align-items: center;
262
- justify-content: center;
263
- cursor: pointer;
264
- transition: background 0.2s;
265
-
266
- &:hover {
267
- background-color: #e0e0e0;
268
- }
267
+ .cpicker__box {
268
+ width: 28px;
269
+ height: 28px;
270
+ border: 2px solid #dee2e6;
271
+ border-radius: 6px;
272
+ background-color: #ffffff;
273
+ position: relative;
274
+ overflow: hidden;
275
+ transition: all 0.2s ease;
276
+ box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
277
+ flex-shrink: 0;
278
+
279
+ /* Checkerboard pattern for transparency */
280
+ background-image:
281
+ linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
282
+ linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
283
+ linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
284
+ linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
285
+ background-size: 6px 6px;
286
+ background-position: 0 0, 0 3px, 3px -3px, -3px 0px;
287
+
288
+ /* When no color is selected */
289
+ &[style=""] {
290
+ background:
291
+ linear-gradient(45deg,
292
+ transparent 40%,
293
+ #dc3545 42%,
294
+ #dc3545 58%,
295
+ transparent 60%
296
+ ),
297
+ linear-gradient(-45deg,
298
+ transparent 40%,
299
+ #dc3545 42%,
300
+ #dc3545 58%,
301
+ transparent 60%
302
+ ),
303
+ #ffffff;
304
+ }
305
+ }
269
306
 
270
- &:focus {
271
- outline: none;
272
- box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
273
- }
307
+ .cpicker-value {
308
+ font-size: 0.8rem;
309
+ font-weight: 500;
310
+ color: #495057;
311
+ font-family: 'Courier New', monospace;
312
+ background: rgba(255, 255, 255, 0.9);
313
+ padding: 3px 6px;
314
+ border-radius: 3px;
315
+ border: 1px solid rgba(0, 0, 0, 0.1);
316
+ flex-grow: 1;
317
+ }
318
+
319
+ .cpicker-container {
320
+ width: 100%;
321
+ display: flex;
322
+ justify-content: center;
323
+ padding: 8px;
324
+ background: #ffffff;
325
+ border: 1px solid #dee2e6;
326
+ border-radius: 6px;
327
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
328
+
329
+ /* Ensure Sketch picker fits well and is more compact */
330
+ .w-color-sketch {
331
+ box-shadow: none !important;
332
+ border: none !important;
333
+ border-radius: 6px !important;
334
+ max-width: 100% !important;
335
+ transform: scale(0.85) !important;
336
+ transform-origin: center top !important;
337
+ }
338
+
339
+ /* Further responsive scaling */
340
+ @media (max-width: 768px) {
341
+ padding: 6px;
342
+
343
+ .w-color-sketch {
344
+ transform: scale(0.75) !important;
345
+ }
346
+ }
347
+
348
+ @media (max-width: 480px) {
349
+ padding: 4px;
350
+
351
+ .w-color-sketch {
352
+ transform: scale(0.65) !important;
274
353
  }
275
354
  }
276
355
  }
277
356
 
357
+
278
358
  .colour_popover {
279
359
  position: absolute;
280
360
  background: white;