413 lines
15 KiB
JavaScript
Executable File
413 lines
15 KiB
JavaScript
Executable File
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { ArrowLeft, Download, FileText } from 'lucide-react';
|
|
import html2pdf from 'html2pdf.js';
|
|
import MinimalTemplate from '../components/invoice-templates/MinimalTemplate';
|
|
|
|
// Available templates
|
|
const templates = {
|
|
minimal: {
|
|
name: 'Minimal',
|
|
description: 'Simple, professional layout',
|
|
component: MinimalTemplate
|
|
}
|
|
};
|
|
|
|
const InvoicePreview = () => {
|
|
const navigate = useNavigate();
|
|
const [invoiceData, setInvoiceData] = useState(null);
|
|
const [pdfPageSize, setPdfPageSize] = useState('A4');
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [selectedTemplate] = useState('minimal');
|
|
|
|
// Calculate totals (same logic as InvoiceEditor)
|
|
const calculateTotals = (items, discount = 0, fees = [], discounts = []) => {
|
|
const subtotal = items.reduce((sum, item) => sum + (item.amount || 0), 0);
|
|
|
|
// Calculate total fees
|
|
const totalFees = fees.reduce((sum, fee) => {
|
|
const feeAmount = fee.type === 'percentage'
|
|
? (subtotal * fee.value) / 100
|
|
: fee.value;
|
|
return sum + feeAmount;
|
|
}, 0);
|
|
|
|
// Calculate total discounts
|
|
const totalDiscounts = discounts.reduce((sum, discountItem) => {
|
|
const discountAmount = discountItem.type === 'percentage'
|
|
? (subtotal * discountItem.value) / 100
|
|
: discountItem.value;
|
|
return sum + discountAmount;
|
|
}, 0);
|
|
|
|
const total = subtotal + totalFees - discount - totalDiscounts;
|
|
return { subtotal, total };
|
|
};
|
|
|
|
// Load invoice data from localStorage
|
|
useEffect(() => {
|
|
try {
|
|
const savedInvoice = localStorage.getItem('currentInvoice');
|
|
const savedPageSize = localStorage.getItem('pdfPageSize');
|
|
|
|
if (savedInvoice) {
|
|
const parsedInvoice = JSON.parse(savedInvoice);
|
|
|
|
// Recalculate totals and installments to ensure accuracy
|
|
const { subtotal, total } = calculateTotals(
|
|
parsedInvoice.items || [],
|
|
parsedInvoice.discount || 0,
|
|
parsedInvoice.fees || [],
|
|
parsedInvoice.discounts || []
|
|
);
|
|
|
|
// Update totals
|
|
parsedInvoice.subtotal = subtotal;
|
|
parsedInvoice.total = total;
|
|
|
|
// Recalculate installments if they exist
|
|
if (parsedInvoice.paymentTerms?.installments?.length > 0) {
|
|
const updatedInstallments = parsedInvoice.paymentTerms.installments.map(installment => {
|
|
if (installment.type === 'percentage') {
|
|
const baseAmount = parsedInvoice.paymentTerms?.type === 'downpayment'
|
|
? total - (parsedInvoice.paymentTerms?.downPayment?.amount || 0)
|
|
: total;
|
|
const amount = (baseAmount * (installment.percentage || 0)) / 100;
|
|
return { ...installment, amount };
|
|
}
|
|
return installment;
|
|
});
|
|
|
|
parsedInvoice.paymentTerms = {
|
|
...parsedInvoice.paymentTerms,
|
|
installments: updatedInstallments
|
|
};
|
|
}
|
|
|
|
setInvoiceData(parsedInvoice);
|
|
} else {
|
|
// No invoice data found, redirect to editor
|
|
navigate('/invoice-editor');
|
|
}
|
|
|
|
if (savedPageSize) {
|
|
setPdfPageSize(savedPageSize);
|
|
}
|
|
} catch (error) {
|
|
navigate('/invoice-editor');
|
|
}
|
|
}, [navigate]);
|
|
|
|
// Format number with thousand separator
|
|
const formatNumber = (num) => {
|
|
const decimalDigits = invoiceData?.settings?.decimalDigits ?? 2;
|
|
if (!invoiceData?.settings?.thousandSeparator) return num.toFixed(decimalDigits);
|
|
return num.toLocaleString('en-US', { minimumFractionDigits: decimalDigits, maximumFractionDigits: decimalDigits });
|
|
};
|
|
|
|
// Format currency
|
|
const formatCurrency = (amount, useThousandSeparator = false) => {
|
|
const symbol = invoiceData?.settings?.currency?.symbol || '$';
|
|
const decimalDigits = invoiceData?.settings?.decimalDigits ?? 2;
|
|
const formattedAmount = useThousandSeparator ? formatNumber(amount) : amount.toFixed(decimalDigits);
|
|
return `${symbol} ${formattedAmount}`;
|
|
};
|
|
|
|
// Utility function to temporarily apply print-optimized styles
|
|
const applyPrintStyles = () => {
|
|
const element = document.getElementById('invoice-content');
|
|
if (!element) return null;
|
|
|
|
// Store original styles
|
|
const originalStyles = new Map();
|
|
|
|
// Find all table elements and store their original styles
|
|
const tables = element.querySelectorAll('table');
|
|
tables.forEach((table, index) => {
|
|
originalStyles.set(`table-${index}`, table.style.cssText);
|
|
table.style.borderCollapse = 'collapse';
|
|
});
|
|
|
|
// Find all th and td elements and apply print styles
|
|
const cells = element.querySelectorAll('th, td');
|
|
cells.forEach((cell, index) => {
|
|
originalStyles.set(`cell-${index}`, cell.style.cssText);
|
|
cell.style.verticalAlign = 'middle';
|
|
cell.style.padding = '4px 12px 20px';
|
|
cell.style.lineHeight = '1.4';
|
|
});
|
|
|
|
// Find all totals rows and apply print styles
|
|
const totalsRows = element.querySelectorAll('.invoice-totals-row, .invoice-total-final');
|
|
totalsRows.forEach((row, index) => {
|
|
originalStyles.set(`totals-${index}`, row.style.cssText);
|
|
row.style.display = 'flex';
|
|
row.style.alignItems = 'center';
|
|
row.style.justifyContent = 'space-between';
|
|
row.style.padding = '4px 12px 20px';
|
|
row.style.minHeight = '44px';
|
|
});
|
|
|
|
// Find all stamp cards and apply print styles
|
|
const stampCards = element.querySelectorAll('.invoice-payment-status-stamp');
|
|
stampCards.forEach((stampCard, index) => {
|
|
originalStyles.set(`stamp-${index}`, stampCard.style.cssText);
|
|
stampCard.style.padding = '4px 12px 20px';
|
|
});
|
|
|
|
// Find all cards and apply print styles
|
|
const fromToCards = element.querySelectorAll('.invoice-from-to-card');
|
|
fromToCards.forEach((fromToCard, index) => {
|
|
originalStyles.set(`fromTo-${index}`, fromToCard.style.cssText);
|
|
fromToCard.style.padding = '4px 12px 20px';
|
|
});
|
|
|
|
return originalStyles;
|
|
};
|
|
|
|
// Utility function to restore original styles
|
|
const restoreOriginalStyles = (originalStyles) => {
|
|
if (!originalStyles) return;
|
|
|
|
const element = document.getElementById('invoice-content');
|
|
if (!element) return;
|
|
|
|
// Restore table styles
|
|
const tables = element.querySelectorAll('table');
|
|
tables.forEach((table, index) => {
|
|
const originalStyle = originalStyles.get(`table-${index}`);
|
|
if (originalStyle !== undefined) {
|
|
table.style.cssText = originalStyle;
|
|
}
|
|
});
|
|
|
|
// Restore cell styles
|
|
const cells = element.querySelectorAll('th, td');
|
|
cells.forEach((cell, index) => {
|
|
const originalStyle = originalStyles.get(`cell-${index}`);
|
|
if (originalStyle !== undefined) {
|
|
cell.style.cssText = originalStyle;
|
|
}
|
|
});
|
|
|
|
// Restore totals row styles
|
|
const totalsRows = element.querySelectorAll('.invoice-totals-row, .invoice-total-final');
|
|
totalsRows.forEach((row, index) => {
|
|
const originalStyle = originalStyles.get(`totals-${index}`);
|
|
if (originalStyle !== undefined) {
|
|
row.style.cssText = originalStyle;
|
|
}
|
|
});
|
|
|
|
// Restore stamp card styles
|
|
const stampCards = element.querySelectorAll('.invoice-payment-status-stamp');
|
|
stampCards.forEach((stampCard, index) => {
|
|
const originalStyle = originalStyles.get(`stamp-${index}`);
|
|
if (originalStyle !== undefined) {
|
|
stampCard.style.cssText = originalStyle;
|
|
}
|
|
});
|
|
|
|
// Restore fromTo card styles
|
|
const fromToCards = element.querySelectorAll('.invoice-from-to-card');
|
|
fromToCards.forEach((fromToCard, index) => {
|
|
const originalStyle = originalStyles.get(`fromTo-${index}`);
|
|
if (originalStyle !== undefined) {
|
|
fromToCard.style.cssText = originalStyle;
|
|
}
|
|
});
|
|
};
|
|
|
|
// Generate PDF from the visible invoice
|
|
const handleDownloadPDF = async () => {
|
|
if (!invoiceData) return;
|
|
|
|
setIsGenerating(true);
|
|
let originalStyles = null;
|
|
|
|
try {
|
|
const element = document.getElementById('invoice-content');
|
|
if (!element) {
|
|
throw new Error('Invoice content not found');
|
|
}
|
|
|
|
// Apply print-optimized styles temporarily
|
|
originalStyles = applyPrintStyles();
|
|
|
|
// Small delay to ensure styles are applied
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
const opt = {
|
|
margin: [0.2, 0.4, 0.8, 0.4], // top, left, bottom, right margins in inches - increased bottom margin to prevent elements dropping
|
|
filename: `invoice-${invoiceData.invoiceNumber || 'draft'}.pdf`,
|
|
image: { type: 'png', quality: 0.98 },
|
|
html2canvas: {
|
|
useCORS: true,
|
|
backgroundColor: '#ffffff',
|
|
letterRendering: true
|
|
},
|
|
jsPDF: {
|
|
unit: 'in',
|
|
format: pdfPageSize === 'F4' ? [8.27, 13] : pdfPageSize.toLowerCase(), // F4 dimensions in inches
|
|
orientation: 'portrait'
|
|
},
|
|
pagebreak: {
|
|
mode: ['avoid-all', 'css', 'legacy'],
|
|
before: '.page-break-before',
|
|
after: '.page-break-after',
|
|
avoid: '.page-break-avoid'
|
|
}
|
|
};
|
|
|
|
await html2pdf().set(opt).from(element).save();
|
|
} catch (error) {
|
|
alert('Failed to generate PDF. Please try again.');
|
|
} finally {
|
|
// Restore original styles after a short delay
|
|
setTimeout(() => {
|
|
restoreOriginalStyles(originalStyles);
|
|
}, 500);
|
|
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
// Navigate back to editor
|
|
const handleEditInvoice = () => {
|
|
// Ensure current invoice data is saved before navigating
|
|
if (invoiceData) {
|
|
try {
|
|
localStorage.setItem('currentInvoice', JSON.stringify(invoiceData));
|
|
} catch (error) {
|
|
// Failed to save invoice data before edit
|
|
}
|
|
}
|
|
// Add a parameter to indicate we're editing existing data
|
|
navigate('/invoice-editor?mode=edit');
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!invoiceData) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600">Loading invoice...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Get selected template component
|
|
const SelectedTemplateComponent = templates[selectedTemplate].component;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100 dark:bg-slate-900">
|
|
|
|
{/* Mobile Notice */}
|
|
<div className="lg:hidden bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-shrink-0">
|
|
<svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
|
Desktop Mode Recommended
|
|
</h3>
|
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
|
For the best preview experience and accurate PDF generation, please use desktop mode or a larger screen. The invoice preview is optimized for desktop viewing.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{/* Invoice Preview */}
|
|
<div className="max-w-5xl mx-auto p-4 sm:p-6" style={{ maxWidth: 'min(60rem, calc(100vw - 2rem))' }}>
|
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-slate-700 overflow-hidden">
|
|
{(
|
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
|
<div className="flex items-center justify-between flex-col sm:flex-row gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Preview</h2>
|
|
<div className="hidden sm:flex items-center gap-2 text-sm text-gray-600 dark:text-gray-600">
|
|
<span>•</span>
|
|
<span>{pdfPageSize} Format</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Back Button */}
|
|
<button
|
|
onClick={handleEditInvoice}
|
|
className="flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
<span className="hidden sm:inline">Back</span>
|
|
</button>
|
|
|
|
{/* Paper Size Selector */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-gray-600 dark:text-gray-300">Size:</label>
|
|
<select
|
|
value={pdfPageSize}
|
|
onChange={(e) => {
|
|
setPdfPageSize(e.target.value);
|
|
localStorage.setItem('pdfPageSize', e.target.value);
|
|
}}
|
|
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
<option value="A4">A4</option>
|
|
<option value="F4">F4</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Download PDF Button */}
|
|
<button
|
|
onClick={handleDownloadPDF}
|
|
disabled={isGenerating}
|
|
className="flex items-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{isGenerating ? 'Generating...' : 'Download PDF'}</span>
|
|
</button>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="p-4 sm:p-6">
|
|
<div className="bg-white rounded-lg shadow-lg overflow-x-auto">
|
|
{/* PDF-Ready Invoice Content */}
|
|
<div
|
|
id="invoice-content"
|
|
className="bg-white"
|
|
style={{
|
|
maxWidth: pdfPageSize === 'A4' ? '720px' : '750px',
|
|
width: '100%',
|
|
minHeight: pdfPageSize === 'A4' ? '720px' : '750px',
|
|
margin: '0 auto',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<SelectedTemplateComponent
|
|
invoiceData={invoiceData}
|
|
formatNumber={formatNumber}
|
|
formatCurrency={formatCurrency}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default InvoicePreview;
|