import React, { useState, useEffect, useMemo } from 'react'; import { Download, Edit3, X, Plus, Phone, Mail, Globe, Calendar, ArrowRight } from 'lucide-react'; // --- Utility Functions --- /** * Converts a date object to YYYY-MM-DD string for input[type="date"] * @param {Date | string} date * @returns {string} */ const dateToInputFormat = (date) => { if (!date) return ''; const d = new Date(date); if (isNaN(d)) return ''; return d.toISOString().split('T')[0]; }; /** * Formats YYYY-MM-DD string for display * @param {string} dateString * @returns {string} */ const formatDateDisplay = (dateString) => { if (!dateString) return 'N/A'; const [year, month, day] = dateString.split('-'); const date = new Date(year, month - 1, day); if (isNaN(date) || year.length !== 4) return dateString; return `${day} ${date.toLocaleString('default', { month: 'long' })} ${year}`; }; /** * Formats a number as a currency string (Rs), without decimal places. * @param {number} num * @returns {string} */ const formatCurrency = (num) => { if (isNaN(num)) return 'Rs 0'; return `Rs ${Math.round(num).toLocaleString('en-IN', { maximumFractionDigits: 0 })}`; }; /** * Increments an alphanumeric number (e.g., 'INV-1001' -> 'INV-1002'). * This is a client-side function and does not persist the value. * @param {string} currentNumber * @returns {string} The incremented number string. */ const incrementNumber = (currentNumber) => { // Regex to find an alphanumeric prefix and a numeric suffix const match = currentNumber.match(/^([A-Za-z-]+)(\d+)$/); if (match) { const prefix = match[1]; const currentNum = parseInt(match[2], 10); const nextNum = currentNum + 1; const numLength = match[2].length; // Pad the number with leading zeros if necessary (e.g., 001 -> 002) return `${prefix}${String(nextNum).padStart(numLength, '0')}`; } // If pattern not found, append '2' or return original return `${currentNumber}-2`; }; // --- API Utility Function with Exponential Backoff --- const withExponentialBackoff = async (apiCall, maxRetries = 5, delay = 1000) => { for (let i = 0; i < maxRetries; i++) { try { return await apiCall(); } catch (error) { if (i === maxRetries - 1) throw error; await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i))); } } }; // --- The Main App Component --- const App = () => { // --- State Management --- const [isEditing, setIsEditing] = useState(true); const [isGenerating, setIsGenerating] = useState(false); const [isTaxRequired, setIsTaxRequired] = useState(false); const [message, setMessage] = useState(''); const [isPdfLibraryLoaded, setIsPdfLibraryLoaded] = useState(false); // AI Notes State const [notesPrompt, setNotesPrompt] = useState('Write professional payment terms for an event service invoice, due in 15 days.'); const [isGeneratingNotes, setIsGeneratingNotes] = useState(false); // Initial state setup (using local defaults now) const [generalData, setGeneralData] = useState({ clientName: 'Hamdard University', eventType: 'Corporate Gala Dinner', eventDate: dateToInputFormat(new Date('2025-09-20')), eventVenue: 'Karachi', invoiceDate: dateToInputFormat(new Date()), // Default numbers (user must edit or use Next button) invoiceNumber: 'INV-1001', poNumber: 'PO-0501', // Placeholder Logo URL logoUrl: 'https://placehold.co/200x50/83a282/ffffff?text=AELIN+LOGO', salesTaxPercent: 17, receivedAmount: 0, notes: 'Payment due within 30 days of the invoice date. Late fees may apply.', issuerName: 'Taha Rao', issuerRank: 'Director', }); const [items, setItems] = useState([ { id: 1, description: 'Venue Setup and Decoration Services', qty: 1, rate: 150000.00 }, { id: 2, description: 'Sound System and Stage Lighting Rental', qty: 1, rate: 45000.00 }, ]); // --- Setup (PDF Library Loading) --- useEffect(() => { const loadScript = (src, onLoadCallback) => { if (document.querySelector(`script[src="${src}"]`)) { onLoadCallback(); return; } const script = document.createElement('script'); script.src = src; script.async = true; script.onload = onLoadCallback; script.onerror = () => console.error(`Failed to load script: ${src}`); document.head.appendChild(script); }; let loadedCount = 0; const totalScripts = 2; const handleLoad = () => { loadedCount++; if (loadedCount === totalScripts) { setTimeout(() => { if (window.html2canvas && (window.jspdf || (window.jspdf && window.jspdf.jsPDF))) { setIsPdfLibraryLoaded(true); showMessage('PDF tools loaded. Ready to download.', false); } else { console.error('PDF libraries did not load into the global window object as expected.'); showMessage('PDF tools failed to load. Please refresh.', true); } }, 200); } }; // Load html2canvas (converts HTML to canvas/image) and jspdf (converts image to PDF) loadScript('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', handleLoad); loadScript('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', handleLoad); }, []); // --- Calculations (Memoized for performance) --- const { subTotal, salesTaxAmount, grandTotal, balance } = useMemo(() => { const currentSubTotal = items.reduce((sum, item) => sum + (item.qty * item.rate), 0); let currentSalesTaxAmount = 0; let currentGrandTotal = currentSubTotal; if (isTaxRequired) { const taxRate = generalData.salesTaxPercent / 100; currentSalesTaxAmount = currentSubTotal * taxRate; currentGrandTotal = currentSubTotal + currentSalesTaxAmount; } const currentBalance = currentGrandTotal - generalData.receivedAmount; return { subTotal: currentSubTotal, salesTaxAmount: currentSalesTaxAmount, grandTotal: currentGrandTotal, balance: currentBalance, }; }, [items, isTaxRequired, generalData.salesTaxPercent, generalData.receivedAmount]); // --- Handlers --- const handleGeneralChange = (e) => { const { id, value, type } = e.target; setGeneralData(prev => ({ ...prev, [id]: type === 'number' ? parseFloat(value) || 0 : value, })); }; const handleItemChange = (id, field, value) => { setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item ) ); }; const addItem = () => { const newId = items.length > 0 ? Math.max(...items.map(i => i.id)) + 1 : 1; setItems(prev => [...prev, { id: newId, description: '', qty: 1, rate: 0 }]); }; const removeItem = (id) => { if (items.length > 1) { setItems(prev => prev.filter(item => item.id !== id)); } }; const showMessage = (msg, isError = false) => { setMessage(msg); setTimeout(() => setMessage(''), 5000); }; const handleIncrement = (field) => { setGeneralData(prev => ({ ...prev, [field]: incrementNumber(prev[field]), })); showMessage(`Incremented ${field === 'invoiceNumber' ? 'Invoice' : 'PO'} Number. This is a client-side function.`, false); }; const generateNotes = async () => { if (!notesPrompt.trim()) { showMessage('Please enter a prompt to generate notes.', true); return; } setIsGeneratingNotes(true); showMessage('Generating notes...', false); const systemPrompt = "You are an invoice generator assistant. Based on the user's prompt, write a concise, professional, and formal single paragraph of payment terms or invoice notes. Do not include a title or introduction. Start directly with the terms."; const userQuery = notesPrompt; const apiKey = ""; const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`; const payload = { contents: [{ parts: [{ text: userQuery }] }], systemInstruction: { parts: [{ text: systemPrompt }] }, }; try { const response = await withExponentialBackoff(() => fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) ); if (!response.ok) { throw new Error(`API call failed with status: ${response.status}`); } const result = await response.json(); const text = result.candidates?.[0]?.content?.parts?.[0]?.text; if (text) { setGeneralData(prev => ({ ...prev, notes: text.trim() })); showMessage('Notes successfully generated!', false); } else { showMessage('Note generation failed: Empty response from AI.', true); } } catch (error) { console.error('Gemini API Error:', error); showMessage('Failed to generate notes. Please check the network or prompt.', true); } finally { setIsGeneratingNotes(false); } }; /** * Core PDF generation function using html2canvas and jspdf. */ const downloadPdf = async () => { if (!isPdfLibraryLoaded) { showMessage('PDF tools are still loading. Please wait a moment.', true); return; } const JsPdfConstructor = window.jspdf?.jsPDF || window.jsPDF; if (typeof JsPdfConstructor !== 'function') { showMessage('PDF generation failed: jspdf library not correctly initialized.', true); console.error("jsPDF constructor not found under expected global names."); return; } setIsGenerating(true); // CRITICAL: Target the element to be converted const input = document.getElementById('invoice-content'); if (!input) { showMessage('Error: Must be in Preview mode to download PDF.', true); setIsGenerating(false); return; } // Temporarily hide controls that shouldn't appear in the PDF screenshot const controls = document.getElementById('pdf-controls'); controls.classList.add('hidden'); try { // Note: The useCORS: true setting is CRITICAL for loading external images like logos. const canvas = await window.html2canvas(input, { scale: 2, useCORS: true, width: input.offsetWidth, height: input.offsetHeight, }); const imgData = canvas.toDataURL('image/jpeg', 1.0); const pdfWidth = 210; // A4 width in mm const pdfHeight = 297; // A4 height in mm const pdf = new JsPdfConstructor('p', 'mm', 'a4'); pdf.addImage(imgData, 'JPEG', 0, 0, pdfWidth, pdfHeight); const filename = `${generalData.clientName.replace(/\s/g, '_')}_Invoice_${generalData.invoiceNumber}.pdf`; pdf.save(filename); showMessage('PDF successfully downloaded!', false); } catch (error) { console.error("PDF generation failed:", error); showMessage('Failed to generate PDF. Check console for details.', true); } finally { // Re-show controls controls.classList.remove('hidden'); setIsGenerating(false); } }; // --- UI Components --- // 1. Invoice Content Preview Component const InvoiceContent = () => (
INVOICE #: {generalData.invoiceNumber}
DATE: {formatDateDisplay(generalData.invoiceDate)}
PO #: {generalData.poNumber}
Aelin Event Pvt Ltd.
NTN: D810933
Jami Commercial Phase 7, DHA, Karachi, Pakistan
{generalData.clientName}
Event: {generalData.eventType}
Date: {formatDateDisplay(generalData.eventDate)}
Venue: {generalData.eventVenue}
| Description | Qty | Rate (Rs) | Total (Rs) |
|---|---|---|---|
| {item.description || "Service Description"} | {item.qty} | {formatCurrency(item.rate)} | {formatCurrency(item.qty * item.rate)} |
{generalData.notes}
{/* Signature Block */}This is a computer generated invoice and is valid without a physical signature.
{generalData.issuerName}
{generalData.issuerRank}
| Subtotal (Excl. Tax): | {formatCurrency(subTotal)} |
| Sales Tax ({generalData.salesTaxPercent}%): | {formatCurrency(salesTaxAmount)} |
| Total Invoice Amount: | {formatCurrency(grandTotal)} |
| (-) Amount Received: | {formatCurrency(generalData.receivedAmount)} |
| BALANCE DUE: | {formatCurrency(balance)} |
Aelin Events - Excellence in Production.
| Description | Qty | Rate | Total | |
|---|---|---|---|---|
| handleItemChange(item.id, 'qty', parseFloat(e.target.value) || 0)} className="w-full text-sm rounded-md border-gray-300 shadow-sm p-1" /> | handleItemChange(item.id, 'rate', parseFloat(e.target.value) || 0)} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 bg-gray-50" /> | {formatCurrency(item.qty * item.rate)} |