import React, { useState, useMemo, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom/client'; // ============================================================================ // TYPES (from types.ts) // ============================================================================ interface LineItem { id: string; description: string; quantity: number; price: number; } // ============================================================================ // COMPONENTS (from components/*.tsx) // ============================================================================ // Icon Component (from components/Icon.tsx) interface IconProps { path: string; className?: string; } const Icon: React.FC = ({ path, className = 'w-6 h-6' }) => ( ); // LogoUploader Component (from components/LogoUploader.tsx) interface LogoUploaderProps { logoUrl: string | null; setLogoUrl: (url: string | null) => void; } const LogoUploader: React.FC = ({ logoUrl, setLogoUrl }) => { const fileInputRef = useRef(null); useEffect(() => { // Cleanup object URL to prevent memory leaks return () => { if (logoUrl && logoUrl.startsWith('blob:')) { URL.revokeObjectURL(logoUrl); } }; }, [logoUrl]); const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { if (logoUrl && logoUrl.startsWith('blob:')) { URL.revokeObjectURL(logoUrl); } setLogoUrl(URL.createObjectURL(file)); } }; const handleRemoveLogo = (e: React.MouseEvent) => { e.stopPropagation(); if (logoUrl) { if (logoUrl.startsWith('blob:')) { URL.revokeObjectURL(logoUrl); } setLogoUrl(null); if(fileInputRef.current) { fileInputRef.current.value = ""; } } }; const triggerFileInput = () => { fileInputRef.current?.click(); }; return (
{logoUrl ? (
Logotipo de la Empresa
) : (
Subir Logotipo
)}
); }; // LineItemRow Component (from components/LineItemRow.tsx) interface LineItemRowProps { item: LineItem; onUpdate: (id: string, newValues: Partial) => void; onRemove: (id: string) => void; currencyFormatter: Intl.NumberFormat; } const LineItemRow: React.FC = ({ item, onUpdate, onRemove, currencyFormatter }) => { const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; let numericValue: string | number = name === 'quantity' || name === 'price' ? parseFloat(value) : value; if (isNaN(numericValue as number)) { numericValue = 0; } onUpdate(item.id, { [name]: numericValue }); }; const lineTotal = item.quantity * item.price; return (
{currencyFormatter.format(lineTotal)}
); }; // ============================================================================ // MAIN APP COMPONENT (from App.tsx) // ============================================================================ // Declara las variables globales para las librerías cargadas por CDN declare const jspdf: any; declare const html2canvas: any; const App: React.FC = () => { const quoteRef = useRef(null); const getInitialState = () => { const savedData = localStorage.getItem('quoteData'); if (savedData) { try { const parsed = JSON.parse(savedData); // Asegura que los datos cargados no sean nulos para los estados iniciales if (parsed) { return { ...parsed, lineItems: parsed.lineItems || [], taxRate: parsed.taxRate ?? 16, currency: parsed.currency || 'MXN', logoUrl: parsed.logoUrl || null, quoteNumber: parsed.quoteNumber || '12345', clientName: parsed.clientName || '', clientAddress: parsed.clientAddress || '', clientEmail: parsed.clientEmail || '', quoteDate: parsed.quoteDate || new Date().toISOString().split('T')[0], validUntilDate: parsed.validUntilDate || '', notes: parsed.notes || 'Gracias por su negocio. Si tiene alguna pregunta, no dude en contactarnos.', }; } } catch (e) { console.error("No se pudieron cargar los datos guardados, usando valores por defecto.", e); } } // Devuelve el estado por defecto si no hay nada guardado o los datos son inválidos return { lineItems: [ { id: crypto.randomUUID(), description: 'Servicios de Diseño Web', quantity: 1, price: 2500 }, { id: crypto.randomUUID(), description: 'Hosting (1 año)', quantity: 1, price: 300 }, ], taxRate: 16, currency: 'MXN', logoUrl: null, quoteNumber: '12345', clientName: '', clientAddress: '', clientEmail: '', quoteDate: new Date().toISOString().split('T')[0], validUntilDate: '', notes: 'Gracias por su negocio. Si tiene alguna pregunta, no dude en contactarnos.', }; }; const [lineItems, setLineItems] = useState(() => getInitialState().lineItems); const [taxRate, setTaxRate] = useState(() => getInitialState().taxRate); const [currency, setCurrency] = useState<'USD' | 'EUR' | 'GBP' | 'JPY' | 'MXN'>(() => getInitialState().currency); const [logoUrl, setLogoUrl] = useState(() => getInitialState().logoUrl); const [quoteNumber, setQuoteNumber] = useState(() => getInitialState().quoteNumber); const [clientName, setClientName] = useState(() => getInitialState().clientName); const [clientAddress, setClientAddress] = useState(() => getInitialState().clientAddress); const [clientEmail, setClientEmail] = useState(() => getInitialState().clientEmail); const [quoteDate, setQuoteDate] = useState(() => getInitialState().quoteDate); const [validUntilDate, setValidUntilDate] = useState(() => getInitialState().validUntilDate); const [notes, setNotes] = useState(() => getInitialState().notes); useEffect(() => { const dataToSave = { lineItems, taxRate, currency, logoUrl, quoteNumber, clientName, clientAddress, clientEmail, quoteDate, validUntilDate, notes, }; localStorage.setItem('quoteData', JSON.stringify(dataToSave)); }, [lineItems, taxRate, currency, logoUrl, quoteNumber, clientName, clientAddress, clientEmail, quoteDate, validUntilDate, notes]); const currencyFormatter = useMemo(() => { return new Intl.NumberFormat('es-MX', { style: 'currency', currency }); }, [currency]); const subtotal = useMemo(() => { return lineItems.reduce((acc, item) => acc + item.quantity * item.price, 0); }, [lineItems]); const taxAmount = useMemo(() => { return subtotal * (taxRate / 100); }, [subtotal, taxRate]); const total = useMemo(() => { return subtotal + taxAmount; }, [subtotal, taxAmount]); const addLineItem = () => { setLineItems([...lineItems, { id: crypto.randomUUID(), description: '', quantity: 1, price: 0 }]); }; const updateLineItem = (id: string, newValues: Partial) => { setLineItems(lineItems.map(item => (item.id === id ? { ...item, ...newValues } : item))); }; const removeLineItem = (id: string) => { setLineItems(lineItems.filter(item => item.id !== id)); }; const handlePrint = () => { window.print(); }; const handleExportPdf = () => { const input = quoteRef.current; if (input) { html2canvas(input, { scale: 2, useCORS: true, // Agrega un tiempo de espera para asegurar que las imágenes (como el logotipo) se carguen // antes de que se renderice el canvas. Esto mejora la fiabilidad de la exportación. imageTimeout: 5000, }).then(canvas => { const imgData = canvas.toDataURL('image/png'); const pdf = new jspdf.jsPDF({ orientation: 'p', unit: 'pt', format: 'letter', }); const pdfWidth = pdf.internal.pageSize.getWidth(); const imgProps= pdf.getImageProperties(imgData); const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width; pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight); pdf.save(`cotizacion-${quoteNumber}.pdf`); }).catch(err => { console.error("Error al exportar a PDF:", err); }); } }; return (

Generador de Cotizaciones

Cotización

# setQuoteNumber(e.target.value)} className="p-1 w-28 text-right font-semibold text-slate-700 border-b-2 border-transparent focus:border-indigo-500 focus:outline-none transition" />

Cliente:

setClientName(e.target.value)} placeholder="Nombre del Cliente" className="w-full p-2 mb-2 border border-slate-200 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" />