pratikfrontend / components /InvoicePDF.tsx
Antaram's picture
Upload 61 files
24c454f verified
Raw
History Blame Contribute Delete
17.3 kB
// 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;