import React, { useState, useRef, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
FileText, Building2, User, Plus, Trash2, Upload, Download,
Globe, AlertTriangle, ChevronUp, ChevronDown
} from 'lucide-react';
import ToolLayout from '../components/ToolLayout';
import CodeMirrorEditor from '../components/CodeMirrorEditor';
const InvoiceEditor = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// State management following TableEditor pattern
const [activeTab, setActiveTab] = useState('create');
const [createNewCompleted, setCreateNewCompleted] = useState(false);
const [showInputChangeModal, setShowInputChangeModal] = useState(false);
const [pendingTabChange, setPendingTabChange] = useState(null);
const [inputText, setInputText] = useState('');
const [url, setUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const fileInputRef = useRef(null);
const logoInputRef = useRef(null);
const [pasteCollapsed, setPasteCollapsed] = useState(false);
const [pasteDataSummary, setPasteDataSummary] = useState(null);
const [exportExpanded, setExportExpanded] = useState(false);
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false);
// Invoice data structure
const [invoiceData, setInvoiceData] = useState({
invoiceNumber: '',
date: new Date().toISOString().split('T')[0],
dueDate: '',
company: {
name: '',
address: '',
city: '',
phone: '',
email: '',
logo: null
},
client: {
name: '',
address: '',
city: '',
phone: '',
email: ''
},
items: [],
fees: [], // Array of {id, label, type: 'fixed'|'percentage', amount, value}
discounts: [], // Array of {id, label, type: 'fixed'|'percentage', amount, value}
subtotal: 0,
discount: 0,
total: 0,
notes: '',
thankYouMessage: '',
authorizedSignedText: '',
digitalSignature: null,
paymentTerms: {
type: 'full', // 'full', 'installment', 'downpayment'
downPayment: {
amount: 0,
percentage: 0,
dueDate: ''
},
installments: []
},
settings: {
colorScheme: '#3B82F6',
currency: { code: 'USD', symbol: '$' },
thousandSeparator: true,
showFromSection: true
},
paymentMethod: {
type: 'none', // 'none', 'bank', 'link', 'qr'
bankDetails: {
bankName: '',
accountName: '',
accountNumber: '',
routingNumber: '',
swiftCode: '',
iban: ''
},
paymentLink: {
url: '',
label: 'Pay Online'
},
qrCode: {
enabled: false,
customImage: null
}
}
});
// Additional state variables
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [showSignaturePad, setShowSignaturePad] = useState(false);
const [pdfPageSize, setPdfPageSize] = useState('A4'); // A4 or F4
const [currencies, setCurrencies] = useState([]);
// Load currencies from JSON file
useEffect(() => {
const loadCurrencies = async () => {
try {
const response = await fetch('/data/currencies.json');
const currencyData = await response.json();
setCurrencies(currencyData);
} catch (error) {
console.error('Failed to load currencies:', error);
// Fallback to basic currencies
setCurrencies([
{ code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' },
{ code: 'USD', name: 'US Dollar', symbol: '$' },
{ code: 'EUR', name: 'Euro', symbol: '€' },
{ code: 'GBP', name: 'British Pound', symbol: '£' },
{ code: 'JPY', name: 'Japanese Yen', symbol: '¥' }
]);
}
};
loadCurrencies();
}, []);
// Load saved data on component mount
useEffect(() => {
try {
const savedInvoice = localStorage.getItem('currentInvoice');
const savedPageSize = localStorage.getItem('pdfPageSize');
const isEditMode = searchParams.get('mode') === 'edit';
if (savedInvoice) {
const parsedInvoice = JSON.parse(savedInvoice);
// Ensure backward compatibility with new fields
const updatedInvoice = {
...parsedInvoice,
fees: parsedInvoice.fees || [],
discounts: parsedInvoice.discounts || [],
company: {
...parsedInvoice.company,
bankName: parsedInvoice.company?.bankName || '',
accountName: parsedInvoice.company?.accountName || '',
accountNumber: parsedInvoice.company?.accountNumber || ''
}
};
setInvoiceData(updatedInvoice);
// If we're in edit mode or have saved data, show the editor section
const hasData = updatedInvoice.invoiceNumber || updatedInvoice.company.name || updatedInvoice.items.length > 0;
if (isEditMode || hasData) {
setCreateNewCompleted(true);
setActiveTab('create');
}
} else if (isEditMode) {
// Edit mode but no data - redirect back to editor without mode
window.location.replace('/invoice-editor');
}
if (savedPageSize) {
setPdfPageSize(savedPageSize);
}
} catch (error) {
console.error('Failed to load saved invoice:', error);
}
}, [searchParams]);
// Auto-save invoice data to localStorage whenever it changes
useEffect(() => {
// Only auto-save if we have meaningful data or if createNewCompleted is true
if (createNewCompleted || invoiceData.invoiceNumber || invoiceData.company.name || invoiceData.items.length > 0) {
try {
localStorage.setItem('currentInvoice', JSON.stringify(invoiceData));
} catch (error) {
console.error('Failed to save invoice:', error);
}
}
}, [invoiceData, createNewCompleted]);
// Safety recalculation - ensure totals are always correct
useEffect(() => {
const { subtotal, total } = calculateTotals(
invoiceData.items,
invoiceData.discount,
invoiceData.fees,
invoiceData.discounts
);
if (invoiceData.subtotal !== subtotal || invoiceData.total !== total) {
setInvoiceData(prev => {
const updated = {
...prev,
subtotal,
total
};
// Recalculate installments when total changes
if (prev.paymentTerms?.installments?.length > 0) {
const updatedInstallments = prev.paymentTerms.installments.map(installment => {
if (installment.type === 'percentage') {
const baseAmount = prev.paymentTerms?.type === 'downpayment'
? total - (prev.paymentTerms?.downPayment?.amount || 0)
: total;
const amount = (baseAmount * (installment.percentage || 0)) / 100;
return { ...installment, amount };
}
return installment;
});
updated.paymentTerms = {
...prev.paymentTerms,
installments: updatedInstallments
};
}
return updated;
});
}
}, [invoiceData.items, invoiceData.discount, invoiceData.fees, invoiceData.discounts, invoiceData.subtotal, invoiceData.total]);
// Save PDF page size to localStorage
useEffect(() => {
try {
localStorage.setItem('pdfPageSize', pdfPageSize);
} catch (error) {
console.error('Failed to save PDF page size:', error);
}
}, [pdfPageSize]);
// Sample invoice data
const sampleInvoiceData = {
invoiceNumber: 'INV-2024-001',
date: '2024-01-15',
dueDate: '2024-02-15',
company: {
name: 'DevTools Inc.',
address: '123 Tech Street',
city: 'San Francisco, CA 94105',
phone: '+1 (555) 123-4567',
email: 'billing@devtools.com',
logo: null,
bankName: 'Chase Bank',
accountName: 'DevTools Inc.',
accountNumber: '1234567890'
},
client: {
name: 'Acme Corporation',
address: '456 Business Ave',
city: 'New York, NY 10001',
phone: '+1 (555) 987-6543',
email: 'accounts@acme.com'
},
items: [
{ id: 1, description: 'Web Development Services', quantity: 40, rate: 125, amount: 5000 },
{ id: 2, description: 'UI/UX Design', quantity: 20, rate: 100, amount: 2000 },
{ id: 3, description: 'Project Management', quantity: 10, rate: 150, amount: 1500 }
],
fees: [
{ id: 1, label: 'Processing Fee', type: 'fixed', value: 50, amount: 50 }
],
discounts: [
{ id: 1, label: 'Early Payment Discount', type: 'percentage', value: 5, amount: 425 }
],
subtotal: 8500,
discount: 0,
total: 8125,
notes: 'Payment due within 30 days.',
thankYouMessage: 'Thank you for your business!',
authorizedSignedText: 'Authorized Signed',
digitalSignature: null,
settings: {
colorScheme: '#3B82F6',
currency: { code: 'USD', symbol: '$' },
thousandSeparator: true
}
};
// Utility functions for formatting
const formatNumber = (num, useThousandSeparator = invoiceData.settings?.thousandSeparator) => {
if (!useThousandSeparator) return num.toString();
return new Intl.NumberFormat('en-US', { minimumFractionDigits: invoiceData.settings?.decimalDigits ?? 2 }).format(num);
};
const formatCurrency = (amount, showSymbol = true) => {
const currency = invoiceData.settings?.currency || { code: 'USD', symbol: '$' };
const decimalDigits = invoiceData.settings?.decimalDigits ?? 2;
const formattedAmount = formatNumber(amount.toFixed(decimalDigits));
if (showSymbol && currency.symbol) {
return `${currency.symbol} ${formattedAmount}`;
} else {
return `${formattedAmount} ${currency.code}`;
}
};
// Helper functions following TableEditor pattern
const hasUserData = () => {
return invoiceData.invoiceNumber ||
invoiceData.company.name ||
invoiceData.client.name ||
invoiceData.items.length > 0;
};
const hasModifiedData = () => {
if (!hasUserData()) return false;
const isSampleData = JSON.stringify(invoiceData) === JSON.stringify(sampleInvoiceData);
return !isSampleData;
};
const clearAllData = () => {
setInvoiceData({
invoiceNumber: '',
date: new Date().toISOString().split('T')[0],
dueDate: '',
company: {
name: '',
address: '',
city: '',
phone: '',
email: '',
logo: null,
bankName: '',
accountName: '',
accountNumber: ''
},
client: {
name: '',
address: '',
city: '',
phone: '',
email: ''
},
items: [],
fees: [],
discounts: [],
subtotal: 0,
discount: 0,
total: 0,
notes: '',
thankYouMessage: '',
authorizedSignedText: '',
digitalSignature: null,
settings: {
colorScheme: '#3B82F6',
currency: { code: 'USD', symbol: '$' },
thousandSeparator: true
}
});
setCreateNewCompleted(false);
setInputText('');
setUrl('');
setError('');
};
// Tab change handling with confirmation
const handleTabChange = (newTab) => {
if (newTab === 'create' && activeTab !== 'create') {
if (hasModifiedData()) {
setPendingTabChange(newTab);
setShowInputChangeModal(true);
} else {
setActiveTab(newTab);
setCreateNewCompleted(false);
}
} else if (hasUserData() && activeTab !== newTab) {
setPendingTabChange(newTab);
setShowInputChangeModal(true);
} else {
setActiveTab(newTab);
if (newTab === 'create' && createNewCompleted) {
setCreateNewCompleted(false);
}
}
};
const confirmInputChange = () => {
if (pendingTabChange === 'create_empty') {
clearAllData();
setCreateNewCompleted(true);
} else if (pendingTabChange === 'create_sample') {
clearAllData();
setInvoiceData(sampleInvoiceData);
setCreateNewCompleted(true);
} else {
clearAllData();
setActiveTab(pendingTabChange);
if (pendingTabChange === 'create') {
setCreateNewCompleted(false);
}
}
setShowInputChangeModal(false);
setPendingTabChange(null);
};
const cancelInputChange = () => {
setShowInputChangeModal(false);
setPendingTabChange(null);
if (activeTab === 'create' && !createNewCompleted) {
setCreateNewCompleted(true);
}
};
// Create New button handlers
const handleStartEmpty = () => {
if (hasModifiedData()) {
setPendingTabChange('create_empty');
setShowInputChangeModal(true);
} else {
clearAllData();
setCreateNewCompleted(true);
}
};
const handleLoadSample = () => {
if (hasModifiedData()) {
setPendingTabChange('create_sample');
setShowInputChangeModal(true);
} else {
setInvoiceData(sampleInvoiceData);
setCreateNewCompleted(true);
}
};
// Calculate totals
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 };
};
// Update invoice data
const updateInvoiceData = (field, value) => {
setInvoiceData(prev => {
const updated = { ...prev, [field]: value };
// Recalculate totals if items, discount, fees, or discounts changed
if (field === 'items' || field === 'discount' || field === 'fees' || field === 'discounts') {
const { subtotal, total } = calculateTotals(
field === 'items' ? value : updated.items,
field === 'discount' ? value : updated.discount,
field === 'fees' ? value : updated.fees,
field === 'discounts' ? value : updated.discounts
);
updated.subtotal = subtotal;
updated.total = total;
}
return updated;
});
};
// Line item management
const addLineItem = () => {
const newItem = {
id: Date.now(),
description: '',
quantity: 1,
rate: 0,
amount: 0
};
updateInvoiceData('items', [...invoiceData.items, newItem]);
};
const updateLineItem = (id, field, value) => {
const updatedItems = invoiceData.items.map(item => {
if (item.id === id) {
const updated = { ...item, [field]: value };
// Calculate amount when quantity or rate changes
if (field === 'quantity' || field === 'rate') {
updated.amount = updated.quantity * updated.rate;
}
return updated;
}
return item;
});
updateInvoiceData('items', updatedItems);
};
const removeLineItem = (id) => {
updateInvoiceData('items', invoiceData.items.filter(item => item.id !== id));
};
// Move up/down handlers
const moveItem = (arrayName, id, direction) => {
const array = invoiceData[arrayName];
const currentIndex = array.findIndex(item => item.id === id);
if (direction === 'up' && currentIndex > 0) {
const newArray = [...array];
[newArray[currentIndex], newArray[currentIndex - 1]] = [newArray[currentIndex - 1], newArray[currentIndex]];
updateInvoiceData(arrayName, newArray);
} else if (direction === 'down' && currentIndex < array.length - 1) {
const newArray = [...array];
[newArray[currentIndex], newArray[currentIndex + 1]] = [newArray[currentIndex + 1], newArray[currentIndex]];
updateInvoiceData(arrayName, newArray);
}
};
// Move installments (nested in paymentTerms)
const moveInstallment = (id, direction) => {
const installments = invoiceData.paymentTerms?.installments || [];
const currentIndex = installments.findIndex(item => item.id === id);
if (direction === 'up' && currentIndex > 0) {
const newArray = [...installments];
[newArray[currentIndex], newArray[currentIndex - 1]] = [newArray[currentIndex - 1], newArray[currentIndex]];
updateInvoiceData('paymentTerms', {
...invoiceData.paymentTerms,
installments: newArray
});
} else if (direction === 'down' && currentIndex < installments.length - 1) {
const newArray = [...installments];
[newArray[currentIndex], newArray[currentIndex + 1]] = [newArray[currentIndex + 1], newArray[currentIndex]];
updateInvoiceData('paymentTerms', {
...invoiceData.paymentTerms,
installments: newArray
});
}
};
// Fee management
const addFee = () => {
const newFee = {
id: Date.now(),
label: '',
type: 'fixed',
value: 0,
amount: 0
};
updateInvoiceData('fees', [...(invoiceData.fees || []), newFee]);
};
const updateFee = (id, field, value) => {
const updatedFees = (invoiceData.fees || []).map(fee => {
if (fee.id === id) {
const updatedFee = { ...fee, [field]: value };
// Calculate amount based on type
if (field === 'value' || field === 'type') {
updatedFee.amount = updatedFee.type === 'percentage'
? (invoiceData.subtotal * updatedFee.value) / 100
: updatedFee.value;
}
return updatedFee;
}
return fee;
});
updateInvoiceData('fees', updatedFees);
};
const removeFee = (id) => {
const updatedFees = (invoiceData.fees || []).filter(fee => fee.id !== id);
updateInvoiceData('fees', updatedFees);
};
// Discount management
const addDiscount = () => {
const newDiscount = {
id: Date.now(),
label: '',
type: 'fixed',
value: 0,
amount: 0
};
updateInvoiceData('discounts', [...(invoiceData.discounts || []), newDiscount]);
};
const updateDiscount = (id, field, value) => {
const updatedDiscounts = (invoiceData.discounts || []).map(discount => {
if (discount.id === id) {
const updatedDiscount = { ...discount, [field]: value };
// Calculate amount based on type
if (field === 'value' || field === 'type') {
updatedDiscount.amount = updatedDiscount.type === 'percentage'
? (invoiceData.subtotal * updatedDiscount.value) / 100
: updatedDiscount.value;
}
return updatedDiscount;
}
return discount;
});
updateInvoiceData('discounts', updatedDiscounts);
};
const removeDiscount = (id) => {
const updatedDiscounts = (invoiceData.discounts || []).filter(discount => discount.id !== id);
updateInvoiceData('discounts', updatedDiscounts);
};
// Navigate to Invoice Preview for PDF generation
const handleGeneratePreview = () => {
try {
// Save current invoice data to localStorage
localStorage.setItem('currentInvoice', JSON.stringify(invoiceData));
localStorage.setItem('pdfPageSize', pdfPageSize);
// Navigate to preview page
navigate('/invoice-preview');
} catch (error) {
console.error('Failed to save invoice data:', error);
alert('Failed to save invoice data. Please try again.');
}
};
// JSON Export/Import
const exportJSON = () => {
const jsonString = JSON.stringify(invoiceData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `invoice-${invoiceData.invoiceNumber || 'template'}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Handle file import (same as Table Editor)
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const importedData = JSON.parse(content);
setInvoiceData(importedData);
setCreateNewCompleted(true);
setError('');
} catch (err) {
setError('Invalid JSON file format');
}
};
reader.readAsText(file);
}
};
// URL fetching with Google Drive support
const handleUrlFetch = async () => {
if (!url.trim()) {
setError('Please enter a URL');
return;
}
setIsLoading(true);
setError('');
try {
let fetchUrl = url;
// Convert Google Drive share link to direct download link
if (url.includes('drive.google.com/file/d/')) {
const fileId = url.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1];
if (fileId) {
fetchUrl = `https://drive.google.com/uc?export=download&id=${fileId}`;
}
}
const response = await fetch(fetchUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const text = await response.text();
const importedData = JSON.parse(text);
setInvoiceData(importedData);
setCreateNewCompleted(true);
setActiveTab('create');
setError('');
} catch (err) {
setError(`Failed to fetch data: ${err.message}`);
} finally {
setIsLoading(false);
}
};
// Logo upload handling
const handleLogoUpload = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
updateInvoiceData('company', {
...invoiceData.company,
logo: e.target.result
});
};
reader.readAsDataURL(file);
}
};
const removeLogo = () => {
updateInvoiceData('company', {
...invoiceData.company,
logo: null
});
};
// Handle signature upload
const handleSignatureUpload = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
updateInvoiceData('digitalSignature', e.target.result);
};
reader.readAsDataURL(file);
}
};
// Settings update functions
const updateSettings = (key, value) => {
if (key === 'company') {
updateInvoiceData('company', value);
} else if (key === 'paymentMethod') {
updateInvoiceData('paymentMethod', value);
} else {
updateInvoiceData('settings', {
...invoiceData.settings,
[key]: value
});
}
};
return (
Choose how you'd like to begin creating your professional invoice
Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.
Invalid Data: {error}
🔒 Privacy: Your data stays in your browser. We don't store or upload anything - just help you open, edit, and export your files locally.
Use the input section above to create a new invoice or load existing data.
Upload an image of your signature (PNG, JPG recommended)
Tip: Save as JSON to create reusable invoice templates. Load them later using the "Open" tab above.
📝 Input Methods: ✏️ Invoice Editing: 🎨 Customization: 📤 Export Options: 💾 Data Privacy: {error}
Start Building Your Invoice
Invoice Editor
No Invoice Data Loaded
) : (
INVOICE
updateInvoiceData('company', { ...invoiceData.company, name: e.target.value })}
className="bg-transparent border-0 text-white placeholder-white/70 text-sm font-medium focus:outline-none focus:ring-0 p-0"
placeholder="Your Company Name"
/>
FROM
TO
{invoiceData.items.map((item, index) => (
Items
Qty
Rate
Amount
))}
{/* Add Item Row */}
updateLineItem(item.id, 'description', e.target.value)}
className="w-full px-2 py-1 border-0 bg-transparent focus:outline-none focus:ring-0 focus:bg-blue-50 dark:focus:bg-blue-900/20 rounded text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-colors"
placeholder="Enter item description..."
/>
{
const numValue = parseFloat(e.target.value.replace(/,/g, '')) || 0;
updateLineItem(item.id, 'quantity', numValue);
}}
className="w-full px-2 py-1 text-center border-0 bg-transparent focus:outline-none focus:ring-0 focus:bg-blue-50 dark:focus:bg-blue-900/20 rounded text-gray-900 dark:text-white transition-colors"
style={{ width: `${Math.max(formatNumber(item.quantity).length * 8 + 20, 60)}px` }}
onFocus={(e) => {
// Show raw number on focus
e.target.value = item.quantity.toString();
}}
onBlur={(e) => {
// Format with thousand separator on blur
e.target.value = formatNumber(parseFloat(e.target.value.replace(/,/g, '')) || 0);
}}
/>
{formatCurrency(item.amount, true)}
Fees & Discounts
{/* Fees Section */}
Invoice Total
Payment Terms (Optional)
Down Payment
{invoiceData.paymentTerms?.type === 'downpayment' ? 'Remaining Balance Installments' : 'Payment Installments'}
Additional Notes & Signature
Export Invoice
{exportExpanded ?
💡 Usage Tips
{usageTipsExpanded ?
This color will be used throughout the invoice and PDF
Current: {invoiceData.settings?.colorScheme || '#3B82F6'}
Selected: {invoiceData.settings?.currency?.symbol || invoiceData.settings?.currency?.code || '$'} ({invoiceData.settings?.currency?.code || 'USD'})
Number of decimal places to display
Controls the spacing between major sections for better multi-page layout
Use page breaks to ensure important sections start on a new page in PDF output
Choose how payment information appears on your invoice
QR code will be automatically generated from this URL
Add a status stamp to your invoice PDF
Date when payment was received
{newMethod === 'create_empty' || newMethod === 'create_sample' ? `Using ${getMethodName(newMethod)} will clear your current invoice data.` : `Switching from ${getMethodName(currentMethod)} to ${getMethodName(newMethod)} will clear your current invoice data.` }
You currently have:
Tip: Consider downloading your current invoice as JSON before proceeding to save your work.
Draw your signature in the box above using your mouse or touch device.