@visns-studio/visns-components 5.11.7 → 5.11.9
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/Field.jsx +209 -82
- package/src/components/crm/generic/GenericQuote.jsx +504 -126
- package/src/components/crm/generic/styles/GenericQuote.module.scss +382 -78
- package/src/components/crm/styles/Field.module.scss +301 -0
- package/src/components/proposal/ProposalTemplatePreview.css +26 -8
- package/src/components/proposal/ProposalTemplateSectionManager.css +47 -5
- package/src/components/proposal/ProposalTemplateSectionManager.jsx +33 -7
- package/src/components/proposal/SectionEditor.jsx +48 -14
- package/src/components/proposal/SectionTypeSelector.css +54 -0
- package/src/components/proposal/SectionTypeSelector.jsx +15 -3
|
@@ -9,7 +9,8 @@ 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
|
+
import { FileText, DollarSign, Clock, Eye, X, Edit3, FileEdit, Save, Settings } from 'lucide-react';
|
|
13
|
+
import { Editor } from '@tinymce/tinymce-react';
|
|
13
14
|
|
|
14
15
|
import CustomFetch from '../../crm/Fetch';
|
|
15
16
|
import ActionButtons from './ActionButtons';
|
|
@@ -19,6 +20,54 @@ import SortableTableBody from './SortableQuoteItems';
|
|
|
19
20
|
|
|
20
21
|
import styles from './styles/GenericQuote.module.scss';
|
|
21
22
|
|
|
23
|
+
// Isolated Proposal Preview Component
|
|
24
|
+
function ProposalPreviewFrame({ content }) {
|
|
25
|
+
const iframeRef = React.useRef(null);
|
|
26
|
+
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
if (iframeRef.current && content) {
|
|
29
|
+
const iframe = iframeRef.current;
|
|
30
|
+
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
|
31
|
+
|
|
32
|
+
// Write the complete HTML document to iframe
|
|
33
|
+
doc.open();
|
|
34
|
+
doc.write(content);
|
|
35
|
+
doc.close();
|
|
36
|
+
|
|
37
|
+
// Optional: Add responsive styling to iframe body
|
|
38
|
+
const style = doc.createElement('style');
|
|
39
|
+
style.textContent = `
|
|
40
|
+
body {
|
|
41
|
+
margin: 0;
|
|
42
|
+
padding: 20px;
|
|
43
|
+
font-family: Arial, sans-serif;
|
|
44
|
+
background: white;
|
|
45
|
+
overflow-x: auto;
|
|
46
|
+
}
|
|
47
|
+
* {
|
|
48
|
+
box-sizing: border-box;
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
doc.head.appendChild(style);
|
|
52
|
+
}
|
|
53
|
+
}, [content]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<iframe
|
|
57
|
+
ref={iframeRef}
|
|
58
|
+
className={styles.proposalPreview}
|
|
59
|
+
title="Proposal Preview"
|
|
60
|
+
sandbox="allow-same-origin"
|
|
61
|
+
style={{
|
|
62
|
+
width: '100%',
|
|
63
|
+
height: '100%',
|
|
64
|
+
border: 'none',
|
|
65
|
+
background: 'white'
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
22
71
|
function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
23
72
|
const routeParams = useParams();
|
|
24
73
|
const navigate = useNavigate();
|
|
@@ -28,14 +77,16 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
28
77
|
const [data, setData] = useState({});
|
|
29
78
|
const [actions, setActions] = useState(actionItems || []);
|
|
30
79
|
|
|
31
|
-
// Proposal system state (
|
|
32
|
-
const [proposalMode, setProposalMode] = useState(false);
|
|
80
|
+
// Proposal system state (always active)
|
|
33
81
|
const [proposalTemplates, setProposalTemplates] = useState([]);
|
|
34
|
-
const [brandingProfiles, setBrandingProfiles] = useState([]);
|
|
35
82
|
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
|
36
|
-
const [selectedBranding, setSelectedBranding] = useState(null);
|
|
37
83
|
const [proposalPreview, setProposalPreview] = useState(null);
|
|
38
84
|
const [isGeneratingProposal, setIsGeneratingProposal] = useState(false);
|
|
85
|
+
|
|
86
|
+
// Content editor state
|
|
87
|
+
const [showContentEditor, setShowContentEditor] = useState(false);
|
|
88
|
+
const [proposalContent, setProposalContent] = useState('');
|
|
89
|
+
const [contentSectionType, setContentSectionType] = useState('content');
|
|
39
90
|
|
|
40
91
|
// Modal state variables
|
|
41
92
|
const [modalShow, setModalShow] = useState(false);
|
|
@@ -504,55 +555,149 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
504
555
|
if (!proposalConfig) return;
|
|
505
556
|
|
|
506
557
|
try {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
558
|
+
console.log('Loading proposal templates...');
|
|
559
|
+
// Load proposal templates using dropdown endpoint
|
|
560
|
+
const templatesRes = await CustomFetch(
|
|
561
|
+
'/ajax/proposalTemplates/dropdown',
|
|
562
|
+
'POST',
|
|
563
|
+
{}
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
console.log('Templates response:', templatesRes);
|
|
567
|
+
|
|
568
|
+
// Handle different response formats
|
|
569
|
+
let templates = [];
|
|
570
|
+
if (templatesRes.data && templatesRes.data.data && Array.isArray(templatesRes.data.data)) {
|
|
571
|
+
templates = templatesRes.data.data;
|
|
572
|
+
} else if (templatesRes.data && Array.isArray(templatesRes.data)) {
|
|
573
|
+
templates = templatesRes.data;
|
|
574
|
+
} else if (Array.isArray(templatesRes)) {
|
|
575
|
+
templates = templatesRes;
|
|
576
|
+
} else if (templatesRes.success && templatesRes.data) {
|
|
577
|
+
templates = templatesRes.data;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
console.log('Extracted templates:', templates);
|
|
581
|
+
|
|
582
|
+
if (templates && templates.length > 0) {
|
|
583
|
+
setProposalTemplates(templates);
|
|
584
|
+
|
|
585
|
+
// Check if quote already has a saved proposal template
|
|
586
|
+
if (data.proposal_template_id) {
|
|
587
|
+
const savedTemplate = templates.find(t => t.id == data.proposal_template_id);
|
|
588
|
+
if (savedTemplate) {
|
|
589
|
+
console.log('Setting previously saved template:', savedTemplate);
|
|
590
|
+
setSelectedTemplate(data.proposal_template_id);
|
|
591
|
+
} else {
|
|
592
|
+
console.log('Saved template ID not found in available templates:', data.proposal_template_id);
|
|
593
|
+
}
|
|
594
|
+
} else {
|
|
595
|
+
// Set default template if no saved template and available
|
|
596
|
+
const defaultTemplate = templates.find(
|
|
597
|
+
t => t.label?.includes('Standard') || t.label?.includes('Default')
|
|
520
598
|
);
|
|
521
599
|
if (defaultTemplate) {
|
|
600
|
+
console.log('Setting default template:', defaultTemplate);
|
|
522
601
|
setSelectedTemplate(defaultTemplate.id);
|
|
523
602
|
}
|
|
524
603
|
}
|
|
604
|
+
|
|
605
|
+
console.log('Templates successfully loaded:', templates.length, 'templates');
|
|
606
|
+
} else {
|
|
607
|
+
console.log('No templates found in response:', templatesRes);
|
|
525
608
|
}
|
|
526
609
|
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
610
|
+
// Branding is handled at template creation time, no need to load separately
|
|
611
|
+
} catch (error) {
|
|
612
|
+
console.error('Error loading proposal data:', error);
|
|
613
|
+
toast.error('Failed to load proposal templates');
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const calculateGrandTotal = () => {
|
|
618
|
+
if (!tables || tables.length === 0) return 0;
|
|
619
|
+
|
|
620
|
+
return tables.reduce((grandTotal, table) => {
|
|
621
|
+
if (!data[table.id] || data[table.id].length === 0) return grandTotal;
|
|
622
|
+
|
|
623
|
+
const tableTotal = table.total
|
|
624
|
+
? parseFloat(table.total)
|
|
625
|
+
: data[table.id].reduce((sum, row) => {
|
|
626
|
+
let total;
|
|
627
|
+
if (typeof row.total === 'number') {
|
|
628
|
+
total = row.total;
|
|
629
|
+
} else if (row.total) {
|
|
630
|
+
total = parseFloat(
|
|
631
|
+
String(row.total).replace(/[^0-9.-]+/g, '')
|
|
632
|
+
);
|
|
633
|
+
} else if (row.rate && row.qty) {
|
|
634
|
+
const rate = parseFloat(
|
|
635
|
+
String(row.rate).replace(/[^0-9.-]+/g, '')
|
|
636
|
+
);
|
|
637
|
+
const qty = parseFloat(
|
|
638
|
+
String(row.qty).replace(/[^0-9.-]+/g, '')
|
|
639
|
+
);
|
|
640
|
+
total = rate * qty;
|
|
641
|
+
} else {
|
|
642
|
+
total = 0;
|
|
545
643
|
}
|
|
546
|
-
|
|
644
|
+
return sum + (isNaN(total) ? 0 : total);
|
|
645
|
+
}, 0);
|
|
646
|
+
|
|
647
|
+
return grandTotal + (isNaN(tableTotal) ? 0 : tableTotal);
|
|
648
|
+
}, 0);
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const handleTemplateSelection = async (templateId) => {
|
|
652
|
+
setSelectedTemplate(templateId);
|
|
653
|
+
|
|
654
|
+
if (!templateId) return;
|
|
655
|
+
|
|
656
|
+
// Save the selected template to the quote
|
|
657
|
+
try {
|
|
658
|
+
const toastId = toast.loading('Saving template selection...');
|
|
659
|
+
|
|
660
|
+
const updateData = {
|
|
661
|
+
proposal_template_id: templateId
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const response = await CustomFetch(
|
|
665
|
+
`${page.fetchUrl}/${routeParams[urlParam]}`,
|
|
666
|
+
'PUT',
|
|
667
|
+
updateData
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
if (response.success || response.data) {
|
|
671
|
+
// Update local data to reflect the change
|
|
672
|
+
setData(prevData => ({
|
|
673
|
+
...prevData,
|
|
674
|
+
proposal_template_id: templateId
|
|
675
|
+
}));
|
|
676
|
+
|
|
677
|
+
toast.update(toastId, {
|
|
678
|
+
render: 'Template selection saved',
|
|
679
|
+
type: 'success',
|
|
680
|
+
isLoading: false,
|
|
681
|
+
autoClose: 2000,
|
|
682
|
+
closeButton: true,
|
|
683
|
+
});
|
|
684
|
+
} else {
|
|
685
|
+
throw new Error(response.message || 'Failed to save template selection');
|
|
547
686
|
}
|
|
548
687
|
} catch (error) {
|
|
549
|
-
console.error('Error
|
|
550
|
-
toast.error('Failed to
|
|
688
|
+
console.error('Error saving template selection:', error);
|
|
689
|
+
toast.error('Failed to save template selection: ' + error.message);
|
|
690
|
+
|
|
691
|
+
// Revert the selection on error
|
|
692
|
+
setSelectedTemplate(data.proposal_template_id || null);
|
|
551
693
|
}
|
|
552
694
|
};
|
|
553
695
|
|
|
554
696
|
const generateProposalPreview = async () => {
|
|
555
|
-
if (!selectedTemplate
|
|
697
|
+
if (!selectedTemplate) {
|
|
698
|
+
toast.error('Please select a template first');
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
556
701
|
|
|
557
702
|
setIsGeneratingProposal(true);
|
|
558
703
|
const toastId = toast.loading('Generating proposal preview...');
|
|
@@ -562,30 +707,106 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
562
707
|
proposal_data: {
|
|
563
708
|
customer_name: data.customer?.name || 'Customer Name',
|
|
564
709
|
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() :
|
|
710
|
+
`${data.customer.sites[0].address1 || ''} ${data.customer.sites[0].address2 || ''}, ${data.customer.sites[0].suburb || ''} ${data.customer.sites[0].state?.initial || data.customer.sites[0].state?.name || ''} ${data.customer.sites[0].postcode || ''}`.trim() :
|
|
566
711
|
'Customer Address',
|
|
567
|
-
quote_number: data.id || 'Q-001',
|
|
568
|
-
quote_date: data.created_at
|
|
712
|
+
quote_number: String(data.id || 'Q-001'),
|
|
713
|
+
quote_date: data.created_at ? new Date(data.created_at).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
|
569
714
|
current_date: new Date().toISOString().split('T')[0],
|
|
570
715
|
total_amount: formatCurrency(calculateGrandTotal()),
|
|
571
716
|
company_name: 'VISNS Studio',
|
|
717
|
+
company_address: '660C Newcastle Street, Leederville, WA, 6007',
|
|
718
|
+
company_phone: '08 6383 6600',
|
|
719
|
+
company_email: 'team@omniaglobal.com.au',
|
|
720
|
+
document_title: `Proposal for ${data.customer?.name || 'Customer'}`,
|
|
721
|
+
project_manager: 'VISNS Studio',
|
|
572
722
|
terms_and_conditions: data.terms_and_condition || 'Standard terms and conditions apply.',
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
723
|
+
// Provide clean item data - just essential fields as simple arrays
|
|
724
|
+
items_onceoff: (data.items_onceoff || []).map(item => ({
|
|
725
|
+
description: String(item.description || ''),
|
|
726
|
+
rate: parseFloat(item.rate || 0),
|
|
727
|
+
qty: parseFloat(item.qty || 1),
|
|
728
|
+
total: parseFloat((parseFloat(item.rate || 0) * parseFloat(item.qty || 1)).toFixed(2))
|
|
729
|
+
})),
|
|
730
|
+
items_monthly_subscription: (data.items_monthly_subscription || []).map(item => ({
|
|
731
|
+
description: String(item.description || ''),
|
|
732
|
+
rate: parseFloat(item.rate || 0),
|
|
733
|
+
qty: parseFloat(item.qty || 1),
|
|
734
|
+
total: parseFloat((parseFloat(item.rate || 0) * parseFloat(item.qty || 1)).toFixed(2))
|
|
735
|
+
})),
|
|
736
|
+
items_yearly_subscription: (data.items_yearly_subscription || []).map(item => ({
|
|
737
|
+
description: String(item.description || ''),
|
|
738
|
+
rate: parseFloat(item.rate || 0),
|
|
739
|
+
qty: parseFloat(item.qty || 1),
|
|
740
|
+
total: parseFloat((parseFloat(item.rate || 0) * parseFloat(item.qty || 1)).toFixed(2))
|
|
741
|
+
})),
|
|
742
|
+
// Include saved proposal content
|
|
743
|
+
proposal_content: data.proposal_content || null,
|
|
744
|
+
proposal_section_type: data.proposal_section_type || 'content',
|
|
576
745
|
},
|
|
577
|
-
template_id: selectedTemplate,
|
|
578
|
-
|
|
746
|
+
template_id: parseInt(selectedTemplate),
|
|
747
|
+
download: false, // For preview
|
|
748
|
+
paper: 'a4',
|
|
749
|
+
orientation: 'portrait'
|
|
579
750
|
};
|
|
580
751
|
|
|
752
|
+
console.log('Sending proposal data for preview:', JSON.stringify(proposalData, null, 2));
|
|
753
|
+
|
|
581
754
|
const response = await CustomFetch(
|
|
582
|
-
|
|
755
|
+
'/ajax/pdf/preview-proposal',
|
|
583
756
|
'POST',
|
|
584
757
|
proposalData
|
|
585
758
|
);
|
|
586
759
|
|
|
587
|
-
|
|
588
|
-
|
|
760
|
+
console.log('Preview response:', response);
|
|
761
|
+
|
|
762
|
+
if (response.data && response.data.success) {
|
|
763
|
+
// Add DomPDF-compatible styling to the preview HTML to match PDF output
|
|
764
|
+
let htmlContent = response.data.html;
|
|
765
|
+
|
|
766
|
+
// Inject CSS constants for consistent styling
|
|
767
|
+
const cssInjection = `
|
|
768
|
+
<style>
|
|
769
|
+
.omnia-cover-page {
|
|
770
|
+
background-color: #059669 !important;
|
|
771
|
+
color: white !important;
|
|
772
|
+
}
|
|
773
|
+
table {
|
|
774
|
+
border: 2px solid #333333 !important;
|
|
775
|
+
border-collapse: collapse !important;
|
|
776
|
+
}
|
|
777
|
+
table th {
|
|
778
|
+
background-color: #4fbfa5 !important;
|
|
779
|
+
color: white !important;
|
|
780
|
+
border: 1px solid #333333 !important;
|
|
781
|
+
}
|
|
782
|
+
table td {
|
|
783
|
+
border: 1px solid #333333 !important;
|
|
784
|
+
font-weight: 500 !important;
|
|
785
|
+
}
|
|
786
|
+
table tr:nth-child(even) td {
|
|
787
|
+
background-color: #f9f9f9 !important;
|
|
788
|
+
}
|
|
789
|
+
table tfoot tr {
|
|
790
|
+
background-color: #f3f4f6 !important;
|
|
791
|
+
}
|
|
792
|
+
table tfoot tr:last-child {
|
|
793
|
+
background-color: #333333 !important;
|
|
794
|
+
}
|
|
795
|
+
table tfoot tr:last-child td {
|
|
796
|
+
color: white !important;
|
|
797
|
+
font-weight: bold !important;
|
|
798
|
+
}
|
|
799
|
+
</style>
|
|
800
|
+
`;
|
|
801
|
+
|
|
802
|
+
// Insert CSS after the <head> tag or before closing </head>
|
|
803
|
+
if (htmlContent.includes('</head>')) {
|
|
804
|
+
htmlContent = htmlContent.replace('</head>', cssInjection + '</head>');
|
|
805
|
+
} else {
|
|
806
|
+
htmlContent = cssInjection + htmlContent;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
setProposalPreview(htmlContent);
|
|
589
810
|
toast.update(toastId, {
|
|
590
811
|
render: 'Proposal preview generated successfully',
|
|
591
812
|
type: 'success',
|
|
@@ -594,7 +815,8 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
594
815
|
closeButton: true,
|
|
595
816
|
});
|
|
596
817
|
} else {
|
|
597
|
-
|
|
818
|
+
console.error('Preview failed with response:', response);
|
|
819
|
+
throw new Error(response.data?.message || response.data?.error || 'Failed to generate preview');
|
|
598
820
|
}
|
|
599
821
|
} catch (error) {
|
|
600
822
|
console.error('Error generating proposal preview:', error);
|
|
@@ -611,7 +833,10 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
611
833
|
};
|
|
612
834
|
|
|
613
835
|
const generateProposalPDF = async () => {
|
|
614
|
-
if (!selectedTemplate
|
|
836
|
+
if (!selectedTemplate) {
|
|
837
|
+
toast.error('Please select a template first');
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
615
840
|
|
|
616
841
|
setIsGeneratingProposal(true);
|
|
617
842
|
const toastId = toast.loading('Generating proposal PDF...');
|
|
@@ -621,28 +846,53 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
621
846
|
proposal_data: {
|
|
622
847
|
customer_name: data.customer?.name || 'Customer Name',
|
|
623
848
|
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() :
|
|
849
|
+
`${data.customer.sites[0].address1 || ''} ${data.customer.sites[0].address2 || ''}, ${data.customer.sites[0].suburb || ''} ${data.customer.sites[0].state?.initial || data.customer.sites[0].state?.name || ''} ${data.customer.sites[0].postcode || ''}`.trim() :
|
|
625
850
|
'Customer Address',
|
|
626
|
-
quote_number: data.id || 'Q-001',
|
|
627
|
-
quote_date: data.created_at
|
|
851
|
+
quote_number: String(data.id || 'Q-001'),
|
|
852
|
+
quote_date: data.created_at ? new Date(data.created_at).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
|
628
853
|
current_date: new Date().toISOString().split('T')[0],
|
|
629
854
|
total_amount: formatCurrency(calculateGrandTotal()),
|
|
630
855
|
company_name: 'VISNS Studio',
|
|
856
|
+
company_address: '660C Newcastle Street, Leederville, WA, 6007',
|
|
857
|
+
company_phone: '08 6383 6600',
|
|
858
|
+
company_email: 'team@omniaglobal.com.au',
|
|
859
|
+
document_title: `Proposal for ${data.customer?.name || 'Customer'}`,
|
|
860
|
+
project_manager: 'VISNS Studio',
|
|
631
861
|
terms_and_conditions: data.terms_and_condition || 'Standard terms and conditions apply.',
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
862
|
+
// Provide clean item data - just essential fields as simple arrays
|
|
863
|
+
items_onceoff: (data.items_onceoff || []).map(item => ({
|
|
864
|
+
description: String(item.description || ''),
|
|
865
|
+
rate: parseFloat(item.rate || 0),
|
|
866
|
+
qty: parseFloat(item.qty || 1),
|
|
867
|
+
total: parseFloat((parseFloat(item.rate || 0) * parseFloat(item.qty || 1)).toFixed(2))
|
|
868
|
+
})),
|
|
869
|
+
items_monthly_subscription: (data.items_monthly_subscription || []).map(item => ({
|
|
870
|
+
description: String(item.description || ''),
|
|
871
|
+
rate: parseFloat(item.rate || 0),
|
|
872
|
+
qty: parseFloat(item.qty || 1),
|
|
873
|
+
total: parseFloat((parseFloat(item.rate || 0) * parseFloat(item.qty || 1)).toFixed(2))
|
|
874
|
+
})),
|
|
875
|
+
items_yearly_subscription: (data.items_yearly_subscription || []).map(item => ({
|
|
876
|
+
description: String(item.description || ''),
|
|
877
|
+
rate: parseFloat(item.rate || 0),
|
|
878
|
+
qty: parseFloat(item.qty || 1),
|
|
879
|
+
total: parseFloat((parseFloat(item.rate || 0) * parseFloat(item.qty || 1)).toFixed(2))
|
|
880
|
+
})),
|
|
881
|
+
// Include saved proposal content
|
|
882
|
+
proposal_content: data.proposal_content || null,
|
|
883
|
+
proposal_section_type: data.proposal_section_type || 'content',
|
|
635
884
|
},
|
|
636
|
-
template_id: selectedTemplate,
|
|
637
|
-
|
|
638
|
-
filename: `proposal-${data.id || 'draft'}-${new Date().toISOString().split('T')[0]}.pdf`,
|
|
885
|
+
template_id: parseInt(selectedTemplate),
|
|
886
|
+
filename: `${(data.customer?.name || 'Customer').replace(/[/\\?%*:|"<>]/g, '-')} - Proposal #${data.id || 'draft'} - ${(data.label || 'Quote').replace(/[/\\?%*:|"<>]/g, '-')}.pdf`,
|
|
639
887
|
download: true,
|
|
888
|
+
paper: 'a4',
|
|
889
|
+
orientation: 'portrait'
|
|
640
890
|
};
|
|
641
891
|
|
|
642
892
|
// Create a form for file download
|
|
643
893
|
const form = document.createElement('form');
|
|
644
894
|
form.method = 'POST';
|
|
645
|
-
form.action =
|
|
895
|
+
form.action = `/ajax/quotes/${data.id}/generate-proposal`;
|
|
646
896
|
form.style.display = 'none';
|
|
647
897
|
|
|
648
898
|
// Add CSRF token
|
|
@@ -668,12 +918,6 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
668
918
|
templateInput.value = selectedTemplate;
|
|
669
919
|
form.appendChild(templateInput);
|
|
670
920
|
|
|
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
921
|
const filenameInput = document.createElement('input');
|
|
678
922
|
filenameInput.type = 'hidden';
|
|
679
923
|
filenameInput.name = 'filename';
|
|
@@ -706,15 +950,64 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
706
950
|
}
|
|
707
951
|
};
|
|
708
952
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
953
|
+
|
|
954
|
+
const openContentEditor = () => {
|
|
955
|
+
// Load existing content if available
|
|
956
|
+
if (data.proposal_content) {
|
|
957
|
+
setProposalContent(data.proposal_content);
|
|
958
|
+
} else {
|
|
959
|
+
// Set default content template
|
|
960
|
+
setProposalContent(`
|
|
961
|
+
<h2>Project Overview</h2>
|
|
962
|
+
<p>Describe the project background and objectives here...</p>
|
|
963
|
+
|
|
964
|
+
<h2>Proposed Solution</h2>
|
|
965
|
+
<p>Detail your proposed solution and approach...</p>
|
|
966
|
+
|
|
967
|
+
<h2>Benefits</h2>
|
|
968
|
+
<ul>
|
|
969
|
+
<li>Key benefit 1</li>
|
|
970
|
+
<li>Key benefit 2</li>
|
|
971
|
+
<li>Key benefit 3</li>
|
|
972
|
+
</ul>
|
|
973
|
+
|
|
974
|
+
<h2>Implementation Timeline</h2>
|
|
975
|
+
<p>Outline the implementation phases and timeline...</p>
|
|
976
|
+
`.trim());
|
|
715
977
|
}
|
|
716
|
-
|
|
717
|
-
|
|
978
|
+
setShowContentEditor(true);
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
const saveProposalContent = async () => {
|
|
982
|
+
try {
|
|
983
|
+
const response = await CustomFetch(
|
|
984
|
+
`/ajax/quotes/${data.id}/proposal-content`,
|
|
985
|
+
'POST',
|
|
986
|
+
{
|
|
987
|
+
content: proposalContent,
|
|
988
|
+
section_type: contentSectionType
|
|
989
|
+
}
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
if (response.data && response.data.success) {
|
|
993
|
+
toast.success('Proposal content saved successfully');
|
|
994
|
+
setShowContentEditor(false);
|
|
995
|
+
// Update the data object with the new content
|
|
996
|
+
setData(prev => ({
|
|
997
|
+
...prev,
|
|
998
|
+
proposal_content: proposalContent
|
|
999
|
+
}));
|
|
1000
|
+
} else {
|
|
1001
|
+
throw new Error(response.data?.message || 'Failed to save content');
|
|
1002
|
+
}
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
console.error('Error saving proposal content:', error);
|
|
1005
|
+
toast.error('Failed to save proposal content: ' + error.message);
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const handleContentChange = (content) => {
|
|
1010
|
+
setProposalContent(content);
|
|
718
1011
|
};
|
|
719
1012
|
|
|
720
1013
|
const handleAddItem = (tableId) => {
|
|
@@ -763,9 +1056,21 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
763
1056
|
|
|
764
1057
|
useEffect(() => {
|
|
765
1058
|
if (actionItems && actionItems.length > 0) {
|
|
766
|
-
|
|
1059
|
+
// Filter out proposal-related actions when proposal controls are active
|
|
1060
|
+
const filteredActions = proposalConfig
|
|
1061
|
+
? actionItems.filter(action => {
|
|
1062
|
+
// Remove actions that are now handled in the proposal controls section
|
|
1063
|
+
const proposalActionIds = ['generateProposalPDF', 'previewProposal'];
|
|
1064
|
+
const proposalActionLabels = ['Generate Proposal PDF', 'Preview Proposal'];
|
|
1065
|
+
|
|
1066
|
+
return !proposalActionIds.includes(action.id) &&
|
|
1067
|
+
!proposalActionLabels.includes(action.label);
|
|
1068
|
+
})
|
|
1069
|
+
: actionItems;
|
|
1070
|
+
|
|
1071
|
+
setActions(filteredActions);
|
|
767
1072
|
}
|
|
768
|
-
}, [actionItems]);
|
|
1073
|
+
}, [actionItems, proposalConfig]);
|
|
769
1074
|
|
|
770
1075
|
useEffect(() => {
|
|
771
1076
|
const fetchData = async () => {
|
|
@@ -777,6 +1082,11 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
777
1082
|
{}
|
|
778
1083
|
);
|
|
779
1084
|
setData(res.data); // Assuming res is the data to set
|
|
1085
|
+
|
|
1086
|
+
// If quote has a saved proposal template, set it as selected
|
|
1087
|
+
if (res.data && res.data.proposal_template_id) {
|
|
1088
|
+
setSelectedTemplate(res.data.proposal_template_id);
|
|
1089
|
+
}
|
|
780
1090
|
} catch (error) {
|
|
781
1091
|
console.error('Error fetching data:', error);
|
|
782
1092
|
}
|
|
@@ -786,6 +1096,24 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
786
1096
|
fetchData();
|
|
787
1097
|
}, [routeParams['*']]);
|
|
788
1098
|
|
|
1099
|
+
// Load proposal data on component mount
|
|
1100
|
+
useEffect(() => {
|
|
1101
|
+
if (proposalConfig && proposalTemplates.length === 0) {
|
|
1102
|
+
loadProposalData();
|
|
1103
|
+
}
|
|
1104
|
+
}, [proposalConfig]);
|
|
1105
|
+
|
|
1106
|
+
// Update selected template when data changes and templates are available
|
|
1107
|
+
useEffect(() => {
|
|
1108
|
+
if (data.proposal_template_id && proposalTemplates.length > 0 && !selectedTemplate) {
|
|
1109
|
+
const savedTemplate = proposalTemplates.find(t => t.id == data.proposal_template_id);
|
|
1110
|
+
if (savedTemplate) {
|
|
1111
|
+
console.log('Setting saved template from data update:', savedTemplate);
|
|
1112
|
+
setSelectedTemplate(data.proposal_template_id);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}, [data.proposal_template_id, proposalTemplates, selectedTemplate]);
|
|
1116
|
+
|
|
789
1117
|
const handleActionClick = (action) => {
|
|
790
1118
|
// Handle different actions based on action properties
|
|
791
1119
|
console.log(`Action ${action.id} clicked:`, action);
|
|
@@ -1088,30 +1416,9 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
1088
1416
|
</div>
|
|
1089
1417
|
</div>
|
|
1090
1418
|
|
|
1091
|
-
{/* Proposal
|
|
1419
|
+
{/* Proposal Controls (always active) */}
|
|
1092
1420
|
{proposalConfig && (
|
|
1093
1421
|
<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
1422
|
<div className={styles.proposalSettings}>
|
|
1116
1423
|
<div className={styles.proposalSelectors}>
|
|
1117
1424
|
<div className={styles.selectorGroup}>
|
|
@@ -1119,38 +1426,29 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
1119
1426
|
<select
|
|
1120
1427
|
id="templateSelect"
|
|
1121
1428
|
value={selectedTemplate || ''}
|
|
1122
|
-
onChange={(e) =>
|
|
1429
|
+
onChange={(e) => handleTemplateSelection(e.target.value)}
|
|
1123
1430
|
className={styles.proposalSelect}
|
|
1124
1431
|
disabled={isGeneratingProposal}
|
|
1125
1432
|
>
|
|
1126
1433
|
<option value="">Select Template</option>
|
|
1127
1434
|
{proposalTemplates.map((template) => (
|
|
1128
1435
|
<option key={template.id} value={template.id}>
|
|
1129
|
-
{template.
|
|
1436
|
+
{template.label}
|
|
1130
1437
|
</option>
|
|
1131
1438
|
))}
|
|
1132
1439
|
</select>
|
|
1133
1440
|
</div>
|
|
1134
1441
|
|
|
1135
|
-
<div className={styles.
|
|
1136
|
-
<
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
value={selectedBranding || ''}
|
|
1140
|
-
onChange={(e) => setSelectedBranding(e.target.value)}
|
|
1141
|
-
className={styles.proposalSelect}
|
|
1442
|
+
<div className={styles.proposalActions}>
|
|
1443
|
+
<button
|
|
1444
|
+
className={styles.editButton}
|
|
1445
|
+
onClick={openContentEditor}
|
|
1142
1446
|
disabled={isGeneratingProposal}
|
|
1447
|
+
title="Edit proposal content"
|
|
1143
1448
|
>
|
|
1144
|
-
<
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
{profile.name}
|
|
1148
|
-
</option>
|
|
1149
|
-
))}
|
|
1150
|
-
</select>
|
|
1151
|
-
</div>
|
|
1152
|
-
|
|
1153
|
-
<div className={styles.proposalActions}>
|
|
1449
|
+
<Edit3 size={16} aria-hidden="true" />
|
|
1450
|
+
<span>Edit Content</span>
|
|
1451
|
+
</button>
|
|
1154
1452
|
<button
|
|
1155
1453
|
className={styles.previewButton}
|
|
1156
1454
|
onClick={generateProposalPreview}
|
|
@@ -1188,12 +1486,11 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
1188
1486
|
</div>
|
|
1189
1487
|
</div>
|
|
1190
1488
|
</div>
|
|
1191
|
-
)}
|
|
1192
1489
|
</div>
|
|
1193
1490
|
)}
|
|
1194
1491
|
|
|
1195
1492
|
{/* Proposal Preview (if available) */}
|
|
1196
|
-
{
|
|
1493
|
+
{proposalPreview && (
|
|
1197
1494
|
<div className={styles.proposalPreviewContainer}>
|
|
1198
1495
|
<div className={styles.proposalPreviewHeader}>
|
|
1199
1496
|
<h3>Proposal Preview</h3>
|
|
@@ -1205,10 +1502,91 @@ function GenericQuote({ logo, setting, urlParam, proposalConfig = null }) {
|
|
|
1205
1502
|
<X size={16} aria-hidden="true" />
|
|
1206
1503
|
</button>
|
|
1207
1504
|
</div>
|
|
1208
|
-
<
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1505
|
+
<ProposalPreviewFrame content={proposalPreview} />
|
|
1506
|
+
</div>
|
|
1507
|
+
)}
|
|
1508
|
+
|
|
1509
|
+
{/* Content Editor Modal */}
|
|
1510
|
+
{showContentEditor && (
|
|
1511
|
+
<div className={styles.contentEditorOverlay}>
|
|
1512
|
+
<div className={styles.contentEditorModal}>
|
|
1513
|
+
<div className={styles.contentEditorHeader}>
|
|
1514
|
+
<h3>
|
|
1515
|
+
<FileEdit size={20} />
|
|
1516
|
+
Edit Proposal Content
|
|
1517
|
+
</h3>
|
|
1518
|
+
<button
|
|
1519
|
+
className={styles.closeEditor}
|
|
1520
|
+
onClick={() => setShowContentEditor(false)}
|
|
1521
|
+
aria-label="Close editor"
|
|
1522
|
+
>
|
|
1523
|
+
<X size={18} aria-hidden="true" />
|
|
1524
|
+
</button>
|
|
1525
|
+
</div>
|
|
1526
|
+
<div className={styles.contentEditorBody}>
|
|
1527
|
+
<div className={styles.editorSection}>
|
|
1528
|
+
<label className={styles.editorLabel}>
|
|
1529
|
+
<Settings size={16} />
|
|
1530
|
+
Section Type:
|
|
1531
|
+
<select
|
|
1532
|
+
value={contentSectionType}
|
|
1533
|
+
onChange={(e) => setContentSectionType(e.target.value)}
|
|
1534
|
+
className={styles.sectionTypeSelect}
|
|
1535
|
+
>
|
|
1536
|
+
<option value="content">Content Section</option>
|
|
1537
|
+
<option value="overview">Overview Section</option>
|
|
1538
|
+
<option value="solution">Solution Section</option>
|
|
1539
|
+
<option value="benefits">Benefits Section</option>
|
|
1540
|
+
</select>
|
|
1541
|
+
</label>
|
|
1542
|
+
</div>
|
|
1543
|
+
<div className={styles.editorContainer}>
|
|
1544
|
+
<Editor
|
|
1545
|
+
apiKey="qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc"
|
|
1546
|
+
value={proposalContent}
|
|
1547
|
+
onEditorChange={handleContentChange}
|
|
1548
|
+
init={{
|
|
1549
|
+
height: '100%',
|
|
1550
|
+
min_height: 400,
|
|
1551
|
+
resize: false,
|
|
1552
|
+
menubar: true,
|
|
1553
|
+
plugins: [
|
|
1554
|
+
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
|
|
1555
|
+
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
|
1556
|
+
'insertdatetime', 'media', 'table', 'code', 'help', 'wordcount'
|
|
1557
|
+
],
|
|
1558
|
+
toolbar: 'undo redo | blocks | ' +
|
|
1559
|
+
'bold italic forecolor | alignleft aligncenter ' +
|
|
1560
|
+
'alignright alignjustify | bullist numlist outdent indent | ' +
|
|
1561
|
+
'removeformat | help',
|
|
1562
|
+
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
|
|
1563
|
+
branding: false
|
|
1564
|
+
}}
|
|
1565
|
+
/>
|
|
1566
|
+
</div>
|
|
1567
|
+
</div>
|
|
1568
|
+
<div className={styles.contentEditorFooter}>
|
|
1569
|
+
<div className={styles.footerInfo}>
|
|
1570
|
+
Use rich text formatting to create professional proposal content
|
|
1571
|
+
</div>
|
|
1572
|
+
<div className={styles.footerButtons}>
|
|
1573
|
+
<button
|
|
1574
|
+
className={styles.cancelButton}
|
|
1575
|
+
onClick={() => setShowContentEditor(false)}
|
|
1576
|
+
>
|
|
1577
|
+
<X size={16} />
|
|
1578
|
+
Cancel
|
|
1579
|
+
</button>
|
|
1580
|
+
<button
|
|
1581
|
+
className={styles.saveButton}
|
|
1582
|
+
onClick={saveProposalContent}
|
|
1583
|
+
>
|
|
1584
|
+
<Save size={16} />
|
|
1585
|
+
Save Content
|
|
1586
|
+
</button>
|
|
1587
|
+
</div>
|
|
1588
|
+
</div>
|
|
1589
|
+
</div>
|
|
1212
1590
|
</div>
|
|
1213
1591
|
)}
|
|
1214
1592
|
|