Spaces:
Sleeping
Sleeping
| // Unified Invoice PDF Component | |
| // Consolidates PrintInvoice, PdfInvoice, and InvoicePdfGenerator into one reusable component | |
| // Supports: Print, Download PDF, Share to WhatsApp - all with proper Marathi font support | |
| import React, { useState, useCallback } from 'react'; | |
| import jsPDF from 'jspdf'; | |
| import autoTable from 'jspdf-autotable'; | |
| import { Printer, Download, Share2, X, FileText } from 'lucide-react'; | |
| import { Transaction } from '../types'; | |
| import { useToast } from '../context/ToastContext'; | |
| import InvoiceBody from './InvoiceBody'; | |
| // Import shared utilities | |
| import { | |
| formatINR, | |
| formatDate, | |
| getInvoiceMeta, | |
| calculateInvoiceTotals, | |
| getDisplayItems, | |
| formatPotiWeights, | |
| calculatePaidAmount, | |
| generateSafeFileName, | |
| getPaymentBreakdown, | |
| getPaymentModeLabel, | |
| InvoiceTotals, | |
| GroupedInvoiceItem | |
| } from '../utils/invoiceUtils'; | |
| import { | |
| loadDevanagariFonts, | |
| applyDevanagariFont, | |
| renderMarathiText | |
| } from '../utils/pdfFonts'; | |
| // ============================================ | |
| // TYPES | |
| // ============================================ | |
| export type InvoiceAction = 'print' | 'download' | 'share'; | |
| export interface InvoicePDFProps { | |
| transaction: Transaction; | |
| className?: string; | |
| buttonLabel?: string; | |
| showIcon?: boolean; | |
| defaultAction?: InvoiceAction; | |
| onActionComplete?: (action: InvoiceAction, success: boolean) => void; | |
| } | |
| interface InvoicePDFState { | |
| showPreview: boolean; | |
| isGenerating: boolean; | |
| isSharing: boolean; | |
| } | |
| // ============================================ | |
| // COMPONENT | |
| // ============================================ | |
| export const InvoicePDF: React.FC<InvoicePDFProps> = ({ | |
| transaction, | |
| className, | |
| buttonLabel, | |
| showIcon = true, | |
| defaultAction = 'print', | |
| onActionComplete | |
| }) => { | |
| const [state, setState] = useState<InvoicePDFState>({ | |
| showPreview: false, | |
| isGenerating: false, | |
| isSharing: false | |
| }); | |
| const toast = useToast(); | |
| // Calculate invoice data using shared utilities | |
| const meta = getInvoiceMeta(transaction); | |
| const totals = calculateInvoiceTotals(transaction); | |
| const displayItems = getDisplayItems(transaction); // NO GROUPING | |
| const paymentInfo = getPaymentBreakdown(transaction); | |
| // ============================================ | |
| // PDF GENERATION | |
| // ============================================ | |
| const generatePDF = useCallback(async (): Promise<jsPDF> => { | |
| // Load fonts first | |
| await loadDevanagariFonts(); | |
| const doc = new jsPDF({ | |
| orientation: 'portrait', | |
| unit: 'mm', | |
| format: 'a5' | |
| }); | |
| // === HEADER === | |
| doc.setFontSize(24); | |
| doc.setFont('helvetica', 'bold'); | |
| doc.text('INVOICE', 14, 20); | |
| // Bill No and Date | |
| doc.setFontSize(14); | |
| doc.setFont('helvetica', 'bold'); | |
| doc.text(`Bill No: ${transaction.bill_number}`, 140, 15, { align: 'right' }); | |
| doc.setFontSize(12); | |
| doc.setFont('helvetica', 'normal'); | |
| doc.text(`Date: ${formatDate(transaction.bill_date)} (${meta.billTypeLabel})`, 140, 22, { align: 'right' }); | |
| // Header line | |
| doc.setLineWidth(2); | |
| doc.line(14, 28, 140, 28); | |
| // === BILLED TO — BIGGER PARTY NAME === | |
| doc.setFontSize(10); | |
| doc.setFont('helvetica', 'bold'); | |
| doc.setTextColor(85, 85, 85); | |
| doc.text('BILLED TO', 14, 38); | |
| doc.setLineWidth(0.5); | |
| doc.line(14, 39, 45, 39); | |
| doc.setTextColor(0, 0, 0); | |
| doc.setFontSize(22); // BIGGER | |
| doc.setFont('helvetica', 'bold'); | |
| doc.text(transaction.party_name || '-', 14, 48); | |
| const partyPhone = (transaction as any).party_phone; | |
| if (partyPhone) { | |
| doc.setFontSize(11); | |
| doc.setFont('helvetica', 'normal'); | |
| doc.setTextColor(85, 85, 85); | |
| doc.text(`Phone: ${partyPhone}`, 14, 55); | |
| doc.setTextColor(0, 0, 0); | |
| } | |
| // === ITEMS TABLE — NO GROUPING === | |
| const tableData = displayItems.map((item, idx) => { | |
| const potiWeights = formatPotiWeights(item); | |
| const amount = item.net_weight * item.rate_per_kg; | |
| return [ | |
| idx + 1, | |
| `${item.mirchi_name}\nWeights: ${potiWeights}`, | |
| item.poti_count, | |
| item.net_weight.toFixed(2), | |
| formatINR(item.rate_per_kg), | |
| formatINR(amount) | |
| ]; | |
| }); | |
| autoTable(doc, { | |
| startY: 60, | |
| head: [['#', 'मिरची', 'पोती', 'वजन', 'दर', 'एकूण रक्कम']], | |
| body: tableData, | |
| theme: 'grid', | |
| styles: { | |
| fontSize: 10, | |
| cellPadding: 4, | |
| lineWidth: 0.5, | |
| lineColor: [0, 0, 0], | |
| font: 'NotoSansDevanagari' | |
| }, | |
| headStyles: { | |
| fillColor: [255, 255, 255], | |
| textColor: [0, 0, 0], | |
| fontStyle: 'bold', | |
| fontSize: 12 | |
| }, | |
| bodyStyles: { textColor: [0, 0, 0] }, | |
| columnStyles: { | |
| 0: { halign: 'center', cellWidth: 10 }, | |
| 1: { halign: 'left', cellWidth: 45 }, | |
| 2: { halign: 'center', cellWidth: 15 }, | |
| 3: { halign: 'center', cellWidth: 20 }, | |
| 4: { halign: 'right', cellWidth: 20 }, | |
| 5: { halign: 'right', cellWidth: 22 } | |
| } | |
| }); | |
| const finalY = (doc as any).lastAutoTable.finalY + 10; | |
| // === SUMMARY TABLE === | |
| const summaryData: string[][] = [ | |
| ['पॅकिंग', formatINR(totals.packing)], | |
| ['एकूण रक्कम', formatINR(totals.subtotal + totals.packing)] | |
| ]; | |
| if (totals.cess > 0) summaryData.push(['सेस', formatINR(totals.cess)]); | |
| if (totals.adat > 0) summaryData.push(['अडत', formatINR(totals.adat)]); | |
| if (totals.hamali > 0) summaryData.push(['हमाली', formatINR(totals.hamali)]); | |
| if (totals.gaadiBharni > 0) summaryData.push(['गाडी भाडे', formatINR(totals.gaadiBharni)]); | |
| if (totals.otherExpenses > 0) summaryData.push(['इतर खर्च', formatINR(totals.otherExpenses)]); | |
| summaryData.push(['पूर्ण रक्कम', formatINR(totals.grandTotal)]); | |
| // Append Payments to Summary Data | |
| const { payments: payBreakdown, totalPaid, totalDue, isPartial } = paymentInfo; | |
| if (payBreakdown.length > 0) { | |
| summaryData.push([isPartial ? 'जमा (Partial)' : 'जमा', formatINR(totalPaid)]); | |
| payBreakdown.forEach(p => { | |
| summaryData.push([` ${p.modeLabel} ${p.reference ? `(${p.reference})` : ''}`, formatINR(p.amount)]); | |
| }); | |
| } else { | |
| summaryData.push(['जमा', formatINR(0)]); | |
| } | |
| if (totalDue > 0) { | |
| summaryData.push(['येणे बाकी', formatINR(totalDue)]); | |
| } | |
| autoTable(doc, { | |
| startY: finalY, | |
| body: summaryData, | |
| theme: 'grid', | |
| styles: { | |
| fontSize: 10, | |
| cellPadding: 3, | |
| lineWidth: 0.5, | |
| lineColor: [200, 200, 200], | |
| font: 'NotoSansDevanagari' | |
| }, | |
| bodyStyles: { textColor: [0, 0, 0] }, | |
| columnStyles: { | |
| 0: { halign: 'left', cellWidth: 35 }, | |
| 1: { halign: 'right', cellWidth: 30 } | |
| }, | |
| margin: { left: 85 } | |
| }); | |
| return doc; | |
| }, [transaction, meta, totals, displayItems, paymentInfo]); | |
| // ============================================ | |
| // ACTIONS | |
| // ============================================ | |
| const handlePrint = useCallback(async () => { | |
| setState(prev => ({ ...prev, isGenerating: true })); | |
| try { | |
| const doc = await generatePDF(); | |
| doc.autoPrint(); | |
| window.open(doc.output('bloburl'), '_blank'); | |
| toast.success('Invoice sent to printer'); | |
| onActionComplete?.('print', true); | |
| } catch (error) { | |
| console.error('Print failed:', error); | |
| toast.error('Print failed', 'Please try again'); | |
| onActionComplete?.('print', false); | |
| } finally { | |
| setState(prev => ({ ...prev, isGenerating: false })); | |
| } | |
| }, [generatePDF, toast, onActionComplete]); | |
| const handleDownload = useCallback(async () => { | |
| setState(prev => ({ ...prev, isGenerating: true })); | |
| try { | |
| const doc = await generatePDF(); | |
| const fileName = generateSafeFileName( | |
| transaction.party_name || 'Party', | |
| transaction.bill_number, | |
| 'Invoice' | |
| ); | |
| doc.save(fileName); | |
| toast.success('Invoice downloaded'); | |
| onActionComplete?.('download', true); | |
| } catch (error) { | |
| console.error('Download failed:', error); | |
| toast.error('Download failed', 'Please try again'); | |
| onActionComplete?.('download', false); | |
| } finally { | |
| setState(prev => ({ ...prev, isGenerating: false })); | |
| } | |
| }, [generatePDF, transaction, toast, onActionComplete]); | |
| const handleShare = useCallback(async () => { | |
| setState(prev => ({ ...prev, isSharing: true })); | |
| try { | |
| const doc = await generatePDF(); | |
| const pdfBlob = doc.output('blob'); | |
| const fileName = generateSafeFileName( | |
| transaction.party_name || 'Party', | |
| transaction.bill_number, | |
| 'Invoice' | |
| ); | |
| // Try Web Share API first | |
| if (navigator.share) { | |
| try { | |
| const file = new File([pdfBlob], fileName, { type: 'application/pdf' }); | |
| if (navigator.canShare && navigator.canShare({ files: [file] })) { | |
| await navigator.share({ | |
| files: [file], | |
| title: `Invoice ${transaction.bill_number}`, | |
| text: `Invoice for ${transaction.party_name || 'Customer'} - Bill #${transaction.bill_number}` | |
| }); | |
| toast.success('Invoice shared'); | |
| onActionComplete?.('share', true); | |
| return; | |
| } | |
| } catch (err: any) { | |
| if (err.name === 'AbortError') { | |
| // User cancelled | |
| return; | |
| } | |
| } | |
| } | |
| // Fallback: download + WhatsApp link | |
| const url = URL.createObjectURL(pdfBlob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = fileName; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| const text = encodeURIComponent( | |
| `Invoice #${transaction.bill_number}\nType: ${meta.isMixed ? 'Mixed' : (meta.isPatti ? 'Patti' : 'Regular')}\nParty: ${transaction.party_name || ''}\nAmount: ₹${Math.round(totals.grandTotal).toLocaleString()}\nDate: ${formatDate(transaction.bill_date)}\n\nPlease find the invoice attached.` | |
| ); | |
| window.open(`https://wa.me/?text=${text}`, '_blank'); | |
| toast.success('Invoice downloaded', 'Share via WhatsApp opened'); | |
| onActionComplete?.('share', true); | |
| } catch (error) { | |
| console.error('Share failed:', error); | |
| toast.error('Share failed', 'Please try again'); | |
| onActionComplete?.('share', false); | |
| } finally { | |
| setState(prev => ({ ...prev, isSharing: false })); | |
| } | |
| }, [generatePDF, transaction, meta, totals, toast, onActionComplete]); | |
| // ============================================ | |
| // RENDER | |
| // ============================================ | |
| return ( | |
| <> | |
| <button | |
| onClick={() => setState(prev => ({ ...prev, showPreview: true }))} | |
| className={className || "p-2 bg-white text-teal-600 border border-teal-600 rounded-md hover:bg-teal-50 transition-colors flex items-center justify-center gap-2 shadow-sm"} | |
| title="Invoice Options" | |
| > | |
| {showIcon && <FileText size={16} />} | |
| {buttonLabel && <span>{buttonLabel}</span>} | |
| </button> | |
| {state.showPreview && ( | |
| <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-2 sm:p-4"> | |
| <div className="bg-white rounded-lg shadow-2xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden flex flex-col"> | |
| {/* Header */} | |
| <div className="sticky top-0 bg-white border-b border-gray-200 px-3 py-3 sm:px-6 sm:py-4 flex justify-between items-center z-10 shrink-0"> | |
| <h2 className="text-lg sm:text-xl font-bold text-gray-800 truncate mr-2"> | |
| Invoice Preview - {transaction.bill_number} | |
| </h2> | |
| <div className="flex gap-2 items-center"> | |
| <button | |
| onClick={handleShare} | |
| disabled={state.isSharing} | |
| className="px-3 py-1.5 sm:px-4 sm:py-2 text-sm sm:text-base bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors flex items-center gap-1 sm:gap-2 disabled:opacity-50" | |
| > | |
| {state.isSharing ? ( | |
| <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |
| </svg> | |
| ) : ( | |
| <Share2 size={16} /> | |
| )} | |
| <span className="hidden sm:inline">Share</span> | |
| </button> | |
| <button | |
| onClick={handleDownload} | |
| disabled={state.isGenerating} | |
| className="px-3 py-1.5 sm:px-4 sm:py-2 text-sm sm:text-base bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-1 sm:gap-2 disabled:opacity-50" | |
| > | |
| {state.isGenerating ? ( | |
| <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |
| </svg> | |
| ) : ( | |
| <Download size={16} /> | |
| )} | |
| <span className="hidden sm:inline">Download</span> | |
| </button> | |
| <button | |
| onClick={handlePrint} | |
| disabled={state.isGenerating} | |
| className="px-3 py-1.5 sm:px-4 sm:py-2 text-sm sm:text-base bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors flex items-center gap-1 sm:gap-2 disabled:opacity-50" | |
| > | |
| <Printer size={16} /> | |
| <span className="hidden sm:inline">Print</span> | |
| </button> | |
| <button | |
| onClick={() => setState(prev => ({ ...prev, showPreview: false }))} | |
| className="p-1.5 sm:p-2 hover:bg-gray-100 rounded-lg transition-colors" | |
| > | |
| <X size={20} className="text-gray-600" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Preview Content — Uses SAME InvoiceBody */} | |
| <div className="overflow-auto flex-1 p-2 sm:p-8 bg-gray-100"> | |
| <InvoiceBody transaction={transaction} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| ); | |
| }; | |
| export default InvoicePDF; | |