- Added new Markdown Editor with live preview, GFM support, PDF/HTML/DOCX export - Upgraded all paste fields to CodeMirror with syntax highlighting and expand/collapse - Enhanced Object Editor with advanced URL fetching and preview mode - Improved export views with syntax highlighting in Table/Object editors - Implemented SEO improvements (FAQ schema, breadcrumbs, internal linking) - Added Related Tools recommendations component - Created custom 404 page with tool suggestions - Consolidated tools: removed JSON, Serialize, CSV-JSON (merged into main editors) - Updated documentation and cleaned up redundant files - Updated release notes with user-centric improvements
2994 lines
151 KiB
JavaScript
2994 lines
151 KiB
JavaScript
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';
|
||
import SEO from '../components/SEO';
|
||
import RelatedTools from '../components/RelatedTools';
|
||
|
||
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 (
|
||
<>
|
||
<SEO
|
||
title="Free Invoice Generator - Professional Invoice Templates"
|
||
description="✓ Free invoice generator ✓ Professional templates ✓ PDF export ✓ Auto-calculate totals ✓ No signup. Create invoices now!"
|
||
keywords="invoice generator, invoice maker, invoice template, free invoice, invoice editor, pdf invoice, professional invoice, online invoice, invoice creator"
|
||
path="/invoice-editor"
|
||
toolId="invoice-editor"
|
||
/>
|
||
<ToolLayout
|
||
title="Invoice Editor"
|
||
description="Create, edit, and export professional invoices with PDF generation"
|
||
icon={FileText}
|
||
>
|
||
<div className="space-y-4 sm:space-y-6 w-full">
|
||
{/* Input Section */}
|
||
<div className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||
<nav className="flex space-x-2 sm:space-x-8 px-4 sm:px-6 overflow-x-auto" aria-label="Tabs">
|
||
{[
|
||
{ id: 'create', name: 'Create New', icon: Plus },
|
||
{ id: 'url', name: 'URL', icon: Globe },
|
||
{ id: 'paste', name: 'Paste', icon: FileText },
|
||
{ id: 'open', name: 'Open', icon: Upload }
|
||
].map((tab) => {
|
||
const Icon = tab.icon;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => handleTabChange(tab.id)}
|
||
className={`${
|
||
activeTab === tab.id
|
||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||
} whitespace-nowrap py-4 px-1 sm:px-2 border-b-2 font-medium text-sm flex items-center gap-1 sm:gap-2 transition-colors min-w-0 flex-shrink-0`}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
{tab.name}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
{(activeTab !== 'create' || !createNewCompleted) && (
|
||
<div className="p-4">
|
||
{activeTab === 'create' && (
|
||
<div className="text-center py-12">
|
||
<FileText className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4" />
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
Start Building Your Invoice
|
||
</h3>
|
||
<p className="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||
Choose how you'd like to begin creating your professional invoice
|
||
</p>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<button
|
||
onClick={() => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange('create_empty');
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
handleStartEmpty();
|
||
}
|
||
}}
|
||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
||
>
|
||
<Plus className="h-8 w-8 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||
Start Empty
|
||
</span>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
||
Create a blank invoice
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange('create_sample');
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
handleLoadSample();
|
||
}
|
||
}}
|
||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
||
>
|
||
<FileText className="h-8 w-8 text-gray-400 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
||
Load Sample
|
||
</span>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
||
Start with example invoice
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'url' && (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<div className="relative flex-1">
|
||
<input
|
||
type="url"
|
||
value={url}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
placeholder="https://your-url.com/invoice/your-invoice"
|
||
className="tool-input w-full"
|
||
onKeyPress={(e) => e.key === 'Enter' && handleUrlFetch()}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={handleUrlFetch}
|
||
disabled={isLoading || !url.trim()}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
|
||
>
|
||
{isLoading ? 'Fetching...' : 'Fetch Data'}
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
Enter any URL that returns exported JSON data from your previous invoice work.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'paste' && (
|
||
pasteCollapsed ? (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-green-700 dark:text-green-300">
|
||
✓ Invoice loaded: {pasteDataSummary.invoiceNumber || 'New Invoice'}
|
||
</span>
|
||
<button
|
||
onClick={() => setPasteCollapsed(false)}
|
||
className="text-sm text-blue-600 hover:underline"
|
||
>
|
||
Edit Input ▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<CodeMirrorEditor
|
||
value={inputText}
|
||
onChange={setInputText}
|
||
placeholder="Paste your invoice JSON data here..."
|
||
language="json"
|
||
maxLines={12}
|
||
showToggle={true}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
{error && (
|
||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||
<p className="text-sm text-red-600 dark:text-red-400">
|
||
<strong>Invalid Data:</strong> {error}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between flex-shrink-0">
|
||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||
Supports JSON invoice templates
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
try {
|
||
const parsed = JSON.parse(inputText);
|
||
setInvoiceData(parsed);
|
||
setCreateNewCompleted(true);
|
||
setPasteDataSummary({
|
||
invoiceNumber: parsed.invoiceNumber || 'New Invoice',
|
||
size: inputText.length
|
||
});
|
||
setPasteCollapsed(true);
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Invalid JSON format: ' + err.message);
|
||
setPasteCollapsed(false);
|
||
}
|
||
}}
|
||
disabled={!inputText.trim()}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium px-4 py-2 rounded-md transition-colors flex-shrink-0"
|
||
>
|
||
Load Invoice
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{activeTab === 'open' && (
|
||
<div className="space-y-3">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".json"
|
||
onChange={handleFileSelect}
|
||
className="tool-input"
|
||
/>
|
||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||
<p className="text-xs text-green-700 dark:text-green-300">
|
||
🔒 <strong>Privacy:</strong> Your data stays in your browser. We don't store or upload anything - just help you open, edit, and export your files locally.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Main Editor Section */}
|
||
{ (
|
||
<div className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Editor</h2>
|
||
</div>
|
||
{(
|
||
<button
|
||
onClick={() => setShowSettingsModal(true)}
|
||
className="flex items-center gap-2 px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md transition-colors"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
Settings
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||
{!createNewCompleted ? (
|
||
<div className="text-center py-12">
|
||
<FileText className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No Invoice Data Loaded</h3>
|
||
<p className="text-gray-500 dark:text-gray-400">
|
||
Use the input section above to create a new invoice or load existing data.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-8">
|
||
{/* Invoice Header - Like Final Invoice */}
|
||
<div className="text-white rounded-xl p-6 border border-blue-200 dark:border-blue-800" style={{ background: `linear-gradient(to right, ${invoiceData.settings?.colorScheme || '#3B82F6'}, ${invoiceData.settings?.colorScheme || '#3B82F6'}dd)` }}>
|
||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4">
|
||
{/* Logo and Company Name */}
|
||
<div className="flex items-center gap-4">
|
||
{invoiceData.company.logo ? (
|
||
<img
|
||
src={invoiceData.company.logo}
|
||
alt="Company Logo"
|
||
className="h-12 w-12 object-contain bg-white rounded-lg p-1"
|
||
/>
|
||
) : (
|
||
<div className="h-12 w-12 bg-white/20 rounded-lg flex items-center justify-center">
|
||
<Building2 className="h-6 w-6 text-white" />
|
||
</div>
|
||
)}
|
||
<div>
|
||
<h1 className="text-3xl font-bold">INVOICE</h1>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.company.name}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Invoice Details */}
|
||
<div className="text-right space-y-1 flex flex-col items-end">
|
||
<div className="flex items-center gap-2 w-full">
|
||
<span className="text-sm font-medium">#</span>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.invoiceNumber}
|
||
onChange={(e) => updateInvoiceData('invoiceNumber', e.target.value)}
|
||
className="bg-white/20 border border-white/30 text-white placeholder-white/70 text-sm font-medium rounded px-2 py-1 w-32 text-left sm:text-right focus:outline-none focus:ring-1 focus:ring-white/50 w-full"
|
||
placeholder="INV-001"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm w-full">
|
||
<span>Date:</span>
|
||
<input
|
||
type="date"
|
||
value={invoiceData.date}
|
||
onChange={(e) => updateInvoiceData('date', e.target.value)}
|
||
className="bg-white/20 border border-white/30 text-white text-xs rounded px-2 py-1 w-36 focus:outline-none focus:ring-1 focus:ring-white/50 w-full"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm w-full">
|
||
<span>Due:</span>
|
||
<input
|
||
type="date"
|
||
value={invoiceData.dueDate}
|
||
onChange={(e) => updateInvoiceData('dueDate', e.target.value)}
|
||
className="bg-white/20 border border-white/30 text-white text-xs rounded px-2 py-1 w-36 focus:outline-none focus:ring-1 focus:ring-white/50 w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* From and To Section - Invoice Layout Style */}
|
||
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-6">
|
||
{/* From Section */}
|
||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-8 w-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}20`, color: invoiceData.settings?.colorScheme || '#3B82F6' }}>
|
||
<Building2 className="h-4 w-4" />
|
||
</div>
|
||
<h3 className="text-sm font-bold uppercase tracking-wide" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6' }}>FROM</h3>
|
||
</div>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.showFromSection ?? true}
|
||
onChange={(e) => updateInvoiceData('settings', {
|
||
...invoiceData.settings,
|
||
showFromSection: e.target.checked
|
||
})}
|
||
className="w-4 h-4 rounded border-gray-300 focus:ring-2"
|
||
style={{
|
||
accentColor: invoiceData.settings?.colorScheme || '#3B82F6',
|
||
'--tw-ring-color': `${invoiceData.settings?.colorScheme || '#3B82F6'}40`
|
||
}}
|
||
/>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">Show in preview</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className={`space-y-3 ${!(invoiceData.settings?.showFromSection ?? true) ? 'opacity-50 pointer-events-none' : ''}`}>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.company.name}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, name: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="w-full text-lg font-semibold text-gray-900 dark:text-white bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
style={{ '--focus-color': invoiceData.colors?.from || '#3B82F6' }}
|
||
onFocus={(e) => e.target.style.borderBottomColor = invoiceData.colors?.from || '#3B82F6'}
|
||
onBlur={(e) => e.target.style.borderBottomColor = ''}
|
||
placeholder="Company Name"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.company.address}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, address: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="w-full text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
placeholder="Street Address"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.company.city}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, city: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="w-full text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
placeholder="City, State ZIP"
|
||
/>
|
||
<div className="flex gap-3 flex-col sm:flex-row">
|
||
<input
|
||
type="tel"
|
||
value={invoiceData.company.phone}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, phone: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
placeholder="Phone"
|
||
/>
|
||
<span className="text-gray-400 text-sm self-end pb-1 hidden sm:block">|</span>
|
||
<input
|
||
type="email"
|
||
value={invoiceData.company.email}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, email: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
placeholder="Email"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* To Section */}
|
||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<div className="h-8 w-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}20`, color: invoiceData.settings?.colorScheme || '#3B82F6' }}>
|
||
<User className="h-4 w-4" />
|
||
</div>
|
||
<h3 className="text-sm font-bold uppercase tracking-wide" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6' }}>TO</h3>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<input
|
||
type="text"
|
||
value={invoiceData.client.name}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, name: e.target.value })}
|
||
className="w-full text-lg font-semibold text-gray-900 dark:text-white bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="Client Name"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.client.address}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, address: e.target.value })}
|
||
className="w-full text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="Street Address"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.client.city}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, city: e.target.value })}
|
||
className="w-full text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="City, State ZIP"
|
||
/>
|
||
<div className="flex gap-3 flex-col sm:flex-row">
|
||
<input
|
||
type="tel"
|
||
value={invoiceData.client.phone}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, phone: e.target.value })}
|
||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="Phone"
|
||
/>
|
||
<span className="text-gray-400 text-sm self-end pb-1 hidden sm:block">|</span>
|
||
<input
|
||
type="email"
|
||
value={invoiceData.client.email}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, email: e.target.value })}
|
||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="Email"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Items */}
|
||
<div className="rounded-xl border" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}10`, borderColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}40` }}>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}20`, borderBottomColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}40` }}>
|
||
<th className="px-4 py-3 text-left text-sm font-semibold" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6' }}>Items</th>
|
||
<th className="px-3 py-3 text-center text-sm font-semibold" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6', width: 'auto' }}>Qty</th>
|
||
<th className="px-3 py-3 text-center text-sm font-semibold" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6', width: 'auto' }}>Rate</th>
|
||
<th className="px-3 py-3 text-right text-sm font-semibold" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6', width: 'auto' }}>Amount</th>
|
||
<th className="px-2 py-3 text-center w-12"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white dark:bg-gray-800">
|
||
{invoiceData.items.map((item, index) => (
|
||
<tr key={item.id} className="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||
<td className="px-4 py-3 min-w-[225px]">
|
||
<input
|
||
type="text"
|
||
value={item.description}
|
||
onChange={(e) => 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..."
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-3 text-center" style={{ width: 'auto' }}>
|
||
<input
|
||
type="text"
|
||
value={formatNumber(item.quantity)}
|
||
onChange={(e) => {
|
||
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);
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-3 text-center" style={{ width: 'auto' }}>
|
||
<div className="relative flex justify-end items-center">
|
||
<span className="text-gray-500 dark:text-gray-400 px-2 py-1 text-xs rounded-1 bg-gray-100 dark:bg-gray-900/20">{invoiceData.settings?.currency?.symbol || '$'}</span>
|
||
<input
|
||
type="text"
|
||
value={formatNumber(item.rate)}
|
||
onChange={(e) => {
|
||
const numValue = parseFloat(e.target.value.replace(/,/g, '')) || 0;
|
||
updateLineItem(item.id, 'rate', numValue);
|
||
}}
|
||
className="pl-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.rate).length * 8 + 20, 40)}px` }}
|
||
onFocus={(e) => {
|
||
// Show raw number on focus
|
||
e.target.value = item.rate.toString();
|
||
}}
|
||
onBlur={(e) => {
|
||
// Format with thousand separator on blur
|
||
e.target.value = formatNumber(parseFloat(e.target.value.replace(/,/g, '')) || 0);
|
||
}}
|
||
/>
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-3 text-right font-semibold text-gray-900 dark:text-white" style={{ width: 'auto' }}>
|
||
<span style={{ minWidth: `${Math.max(formatCurrency(item.amount, true).length * 8 + 20, 100)}px`, display: 'inline-block' }}>
|
||
{formatCurrency(item.amount, true)}
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-3 text-center">
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => moveItem('items', item.id, 'up')}
|
||
disabled={index === 0}
|
||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move up"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => moveItem('items', item.id, 'down')}
|
||
disabled={index === invoiceData.items.length - 1}
|
||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move down"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => removeLineItem(item.id)}
|
||
className="p-1 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||
title="Delete item"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{/* Add Item Row */}
|
||
<tr className="border-b border-gray-100 dark:border-gray-700 transition-colors" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}05` }}>
|
||
<td colSpan="5" className="px-4 py-3 relative">
|
||
<button
|
||
onClick={addLineItem}
|
||
className="text-left flex items-center gap-2 py-1 transition-colors sticky left-4"
|
||
style={{
|
||
color: invoiceData.settings?.colorScheme || '#3B82F6',
|
||
':hover': { color: `${invoiceData.settings?.colorScheme || '#3B82F6'}dd` }
|
||
}}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Add item
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Totals and Tax */}
|
||
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-6">
|
||
<div className="rounded-xl p-6 border" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}10`, borderColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}40` }}>
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6' }}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||
</svg>
|
||
Fees & Discounts
|
||
</h3>
|
||
|
||
|
||
{/* Fees Section */}
|
||
<div className="mb-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Additional Fees</label>
|
||
<button
|
||
onClick={addFee}
|
||
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300 rounded-md transition-colors"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
Add Fee
|
||
</button>
|
||
</div>
|
||
{(invoiceData.fees || []).map((fee, index) => (
|
||
<div key={fee.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col xl:flex-row gap-2">
|
||
{/* Fee Name */}
|
||
<input
|
||
type="text"
|
||
value={fee.label}
|
||
onChange={(e) => updateFee(fee.id, 'label', e.target.value)}
|
||
placeholder="Fee name"
|
||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||
/>
|
||
|
||
{/* Controls Row */}
|
||
<div className="flex flex-wrap gap-2 flex-col xl:flex-row">
|
||
<input
|
||
type="number"
|
||
value={fee.value}
|
||
onChange={(e) => updateFee(fee.id, 'value', parseFloat(e.target.value) || 0)}
|
||
placeholder="0"
|
||
className="flex-1 xl:flex-0 xl:w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
min="0"
|
||
step="0.01"
|
||
/>
|
||
<select
|
||
value={fee.type}
|
||
onChange={(e) => updateFee(fee.id, 'type', e.target.value)}
|
||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="fixed">Fixed</option>
|
||
<option value="percentage">%</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons - Always on Right */}
|
||
<div className="flex flex-col xl:flex-row gap-1 items-center justify-center">
|
||
<button
|
||
onClick={() => moveItem('fees', fee.id, 'up')}
|
||
disabled={index === 0}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move up"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => moveItem('fees', fee.id, 'down')}
|
||
disabled={index === (invoiceData.fees || []).length - 1}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move down"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => removeFee(fee.id)}
|
||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||
title="Delete fee"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Discounts Section */}
|
||
<div className="mb-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Discounts</label>
|
||
<button
|
||
onClick={addDiscount}
|
||
className="flex items-center gap-1 px-2 py-1 text-xs bg-green-100 hover:bg-green-200 dark:bg-green-900 dark:hover:bg-green-800 text-green-700 dark:text-green-300 rounded-md transition-colors"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
Add Discount
|
||
</button>
|
||
</div>
|
||
{(invoiceData.discounts || []).map((discount, index) => (
|
||
<div key={discount.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col xl:flex-row gap-2">
|
||
{/* Discount Name */}
|
||
<input
|
||
type="text"
|
||
value={discount.label}
|
||
onChange={(e) => updateDiscount(discount.id, 'label', e.target.value)}
|
||
placeholder="Discount name"
|
||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||
/>
|
||
|
||
{/* Controls Row */}
|
||
<div className="flex flex-wrap gap-2 flex-col xl:flex-row">
|
||
<input
|
||
type="number"
|
||
value={discount.value}
|
||
onChange={(e) => updateDiscount(discount.id, 'value', parseFloat(e.target.value) || 0)}
|
||
placeholder="0"
|
||
className="flex-1 xl:flex-0 xl:w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
min="0"
|
||
step="0.01"
|
||
/>
|
||
<select
|
||
value={discount.type}
|
||
onChange={(e) => updateDiscount(discount.id, 'type', e.target.value)}
|
||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="fixed">Fixed</option>
|
||
<option value="percentage">%</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons - Always on Right */}
|
||
<div className="flex flex-col xl:flex-row gap-1 items-center justify-center">
|
||
<button
|
||
onClick={() => moveItem('discounts', discount.id, 'up')}
|
||
disabled={index === 0}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move up"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => moveItem('discounts', discount.id, 'down')}
|
||
disabled={index === (invoiceData.discounts || []).length - 1}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move down"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => removeDiscount(discount.id)}
|
||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||
title="Delete discount"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 rounded-xl p-6 border border-emerald-200 dark:border-emerald-800">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<svg className="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||
</svg>
|
||
Invoice Total
|
||
</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||
<span className="text-gray-600 dark:text-gray-400">Subtotal:</span>
|
||
<span className="font-medium text-gray-900 dark:text-white flex-shrink-0">{formatCurrency(invoiceData.subtotal, true)}</span>
|
||
</div>
|
||
|
||
{/* Dynamic Fees */}
|
||
{(invoiceData.fees || []).map((fee) => (
|
||
<div key={fee.id} className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||
<span className="text-gray-600 dark:text-gray-400">
|
||
{fee.label || 'Fee'} {fee.type === 'percentage' ? `(${fee.value}%)` : ''}:
|
||
</span>
|
||
<span className="font-medium text-blue-600 dark:text-blue-400 flex-shrink-0">
|
||
+{formatCurrency(fee.type === 'percentage' ? (invoiceData.subtotal * fee.value) / 100 : fee.value, true)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Dynamic Discounts */}
|
||
{(invoiceData.discounts || []).map((discount) => (
|
||
<div key={discount.id} className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||
<span className="text-gray-600 dark:text-gray-400">
|
||
{discount.label || 'Discount'} {discount.type === 'percentage' ? `(${discount.value}%)` : ''}:
|
||
</span>
|
||
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">
|
||
-{formatCurrency(discount.type === 'percentage' ? (invoiceData.subtotal * discount.value) / 100 : discount.value, true)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Legacy Discount */}
|
||
{invoiceData.discount > 0 && (
|
||
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||
<span className="text-gray-600 dark:text-gray-400">Discount:</span>
|
||
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">-{formatCurrency(invoiceData.discount, true)}</span>
|
||
</div>
|
||
)}
|
||
<div className="bg-emerald-100 dark:bg-emerald-900/30 rounded-lg p-4 mt-4">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-lg font-semibold text-emerald-800 dark:text-emerald-200">Total:</span>
|
||
<span className="text-2xl font-bold text-emerald-800 dark:text-emerald-200 flex-shrink-0">{formatCurrency(invoiceData.total, true)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Payment Terms - Optional Section */}
|
||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<svg className="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||
</svg>
|
||
Payment Terms (Optional)
|
||
</h3>
|
||
|
||
<div className="space-y-4">
|
||
{/* Payment Type Selection */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Payment Type
|
||
</label>
|
||
<select
|
||
value={invoiceData.paymentTerms?.type || 'full'}
|
||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
type: e.target.value
|
||
})}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="full">Full Payment</option>
|
||
<option value="downpayment">Down Payment + Balance</option>
|
||
<option value="installment">Installments Only</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Down Payment Section */}
|
||
{(invoiceData.paymentTerms?.type === 'downpayment') && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
|
||
<h4 className="text-md font-medium text-gray-900 dark:text-white mb-3">Down Payment</h4>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col lg:flex-row gap-2">
|
||
{/* Down Payment Label */}
|
||
<input
|
||
type="text"
|
||
value="Down Payment"
|
||
readOnly
|
||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white bg-gray-100 dark:bg-gray-600 font-medium"
|
||
/>
|
||
|
||
{/* Controls Row */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<input
|
||
type="number"
|
||
value={
|
||
invoiceData.paymentTerms?.downPayment?.type === 'fixed'
|
||
? (invoiceData.paymentTerms?.downPayment?.amount || 0)
|
||
: (invoiceData.paymentTerms?.downPayment?.percentage || 0)
|
||
}
|
||
onChange={(e) => {
|
||
const value = parseFloat(e.target.value) || 0;
|
||
const isFixed = invoiceData.paymentTerms?.downPayment?.type === 'fixed';
|
||
|
||
let amount, percentage;
|
||
if (isFixed) {
|
||
amount = value;
|
||
percentage = invoiceData.total > 0 ? (amount / invoiceData.total) * 100 : 0;
|
||
} else {
|
||
percentage = value;
|
||
amount = (invoiceData.total * percentage) / 100;
|
||
}
|
||
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
downPayment: {
|
||
...invoiceData.paymentTerms.downPayment,
|
||
percentage,
|
||
amount
|
||
}
|
||
});
|
||
}}
|
||
placeholder="0"
|
||
className="flex-1 lg:flex-0 w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
min="0"
|
||
max="100"
|
||
step="0.01"
|
||
/>
|
||
<select
|
||
value={invoiceData.paymentTerms?.downPayment?.type || 'percentage'}
|
||
onChange={(e) => {
|
||
const type = e.target.value;
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
downPayment: {
|
||
...invoiceData.paymentTerms.downPayment,
|
||
type
|
||
}
|
||
});
|
||
}}
|
||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="percentage">%</option>
|
||
<option value="fixed">Fixed</option>
|
||
</select>
|
||
<input
|
||
type="date"
|
||
value={invoiceData.paymentTerms?.downPayment?.dueDate || ''}
|
||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
downPayment: {
|
||
...invoiceData.paymentTerms.downPayment,
|
||
dueDate: e.target.value
|
||
}
|
||
})}
|
||
className="flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
/>
|
||
<select
|
||
value={invoiceData.paymentTerms?.downPayment?.status || 'pending'}
|
||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
downPayment: {
|
||
...invoiceData.paymentTerms.downPayment,
|
||
status: e.target.value
|
||
}
|
||
})}
|
||
className={`flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white ${
|
||
invoiceData.paymentTerms?.downPayment?.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
|
||
invoiceData.paymentTerms?.downPayment?.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
|
||
invoiceData.paymentTerms?.downPayment?.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
|
||
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
<option value="pending">Pending</option>
|
||
<option value="current">Current</option>
|
||
<option value="paid">Paid</option>
|
||
<option value="overdue">Overdue</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{invoiceData.paymentTerms?.downPayment?.amount > 0 && (
|
||
<div className="mt-3 p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-sm text-blue-800 dark:text-blue-200">
|
||
Remaining Balance: {formatCurrency(invoiceData.total - (invoiceData.paymentTerms?.downPayment?.amount || 0), true)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Installments Section */}
|
||
{(invoiceData.paymentTerms?.type === 'installment' || invoiceData.paymentTerms?.type === 'downpayment') && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
|
||
<div className="flex justify-between items-center mb-3">
|
||
<h4 className="text-md font-medium text-gray-900 dark:text-white">
|
||
{invoiceData.paymentTerms?.type === 'downpayment' ? 'Remaining Balance Installments' : 'Payment Installments'}
|
||
</h4>
|
||
<button
|
||
onClick={() => {
|
||
const newInstallment = {
|
||
id: Date.now(),
|
||
amount: 0,
|
||
percentage: 0,
|
||
dueDate: '',
|
||
description: `Installment ${(invoiceData.paymentTerms?.installments?.length || 0) + 1}`
|
||
};
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: [...(invoiceData.paymentTerms?.installments || []), newInstallment]
|
||
});
|
||
}}
|
||
className="flex items-center gap-1 px-2 py-1 text-xs bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900 dark:hover:bg-indigo-800 text-indigo-700 dark:text-indigo-300 rounded-md transition-colors whitespace-nowrap"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
Add Term
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{(invoiceData.paymentTerms?.installments || []).map((installment, index) => (
|
||
<div key={installment.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col lg:flex-row gap-2">
|
||
{/* Installment Name - Full Width */}
|
||
<input
|
||
type="text"
|
||
value={installment.description}
|
||
onChange={(e) => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, description: e.target.value } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
placeholder="Installment name"
|
||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||
/>
|
||
|
||
{/* Controls Row */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<input
|
||
type="number"
|
||
value={installment.type === 'percentage' ? (installment.percentage || 0) : (installment.amount || 0)}
|
||
onChange={(e) => {
|
||
const value = parseFloat(e.target.value) || 0;
|
||
const baseAmount = invoiceData.paymentTerms?.type === 'downpayment'
|
||
? invoiceData.total - (invoiceData.paymentTerms?.downPayment?.amount || 0)
|
||
: invoiceData.total;
|
||
|
||
let amount, percentage;
|
||
if (installment.type === 'percentage') {
|
||
percentage = value;
|
||
amount = (baseAmount * percentage) / 100;
|
||
} else {
|
||
amount = value;
|
||
percentage = baseAmount > 0 ? (amount / baseAmount) * 100 : 0;
|
||
}
|
||
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, amount, percentage } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
placeholder="0"
|
||
className="flex-1 lg:flex-0 w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
min="0"
|
||
step="0.01"
|
||
/>
|
||
<select
|
||
value={installment.type || 'fixed'}
|
||
onChange={(e) => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, type: e.target.value } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="fixed">Fixed</option>
|
||
<option value="percentage">%</option>
|
||
</select>
|
||
<input
|
||
type="date"
|
||
value={installment.dueDate}
|
||
onChange={(e) => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, dueDate: e.target.value } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
className="flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
/>
|
||
<select
|
||
value={installment.status || 'pending'}
|
||
onChange={(e) => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, status: e.target.value } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
className={`flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white ${
|
||
installment.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
|
||
installment.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
|
||
installment.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
|
||
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
<option value="pending">Pending</option>
|
||
<option value="current">Current</option>
|
||
<option value="paid">Paid</option>
|
||
<option value="overdue">Overdue</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons - Always on Right */}
|
||
<div className="flex flex-col lg:flex-row gap-1 items-center justify-center">
|
||
<button
|
||
onClick={() => moveInstallment(installment.id, 'up')}
|
||
disabled={index === 0}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move up"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => moveInstallment(installment.id, 'down')}
|
||
disabled={index === (invoiceData.paymentTerms?.installments || []).length - 1}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move down"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.filter(inst => inst.id !== installment.id);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors mt-12 lg:mt-0"
|
||
title="Delete installment"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Additional Notes & Signature - Independent Card */}
|
||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-xl p-6 border border-amber-200 dark:border-amber-800">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
Additional Notes & Signature
|
||
</h3>
|
||
|
||
<div className="space-y-4">
|
||
{/* Additional Notes */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Additional Notes
|
||
</label>
|
||
<textarea
|
||
value={invoiceData.notes}
|
||
onChange={(e) => updateInvoiceData('notes', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-amber-500 focus:border-amber-500 dark:bg-gray-700 dark:text-white resize-none"
|
||
rows="3"
|
||
placeholder="Payment terms, special instructions..."
|
||
/>
|
||
</div>
|
||
|
||
{/* Thank You Message */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Thank You Message
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.thankYouMessage}
|
||
onChange={(e) => updateInvoiceData('thankYouMessage', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-amber-500 focus:border-amber-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="Thank you for your business!"
|
||
/>
|
||
</div>
|
||
|
||
{/* Authorized Signature Text */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Signature Label
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.authorizedSignedText}
|
||
onChange={(e) => updateInvoiceData('authorizedSignedText', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-amber-500 focus:border-amber-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="Authorized Signed"
|
||
/>
|
||
</div>
|
||
|
||
{/* Digital Signature Upload */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Digital Signature
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
{invoiceData.digitalSignature ? (
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={invoiceData.digitalSignature}
|
||
alt="Digital Signature"
|
||
className="h-12 w-auto object-contain border border-gray-300 rounded-lg bg-white p-1"
|
||
/>
|
||
<button
|
||
onClick={() => updateInvoiceData('digitalSignature', null)}
|
||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 border border-red-300 hover:border-red-400 rounded-md transition-colors"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => document.getElementById('signature-upload').click()}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
Upload Image
|
||
</button>
|
||
<button
|
||
onClick={() => setShowSignaturePad(true)}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||
</svg>
|
||
Draw Signature
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<input
|
||
id="signature-upload"
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleSignatureUpload}
|
||
className="hidden"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Upload an image of your signature (PNG, JPG recommended)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Export Section */}
|
||
{(activeTab !== 'create' || createNewCompleted) && createNewCompleted && (
|
||
<div className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||
<div
|
||
onClick={() => setExportExpanded(!exportExpanded)}
|
||
className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Download className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Export Invoice</h2>
|
||
{exportExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{exportExpanded && (
|
||
<div className="p-4 sm:p-6">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<button
|
||
onClick={handleGeneratePreview}
|
||
className="flex items-center justify-center gap-3 px-6 py-4 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||
>
|
||
<FileText className="h-5 w-5" />
|
||
<div className="text-left">
|
||
<div className="font-medium">Generate PDF</div>
|
||
<div className="text-sm opacity-90">Preview & download PDF</div>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={exportJSON}
|
||
className="flex items-center justify-center gap-3 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
<Download className="h-5 w-5" />
|
||
<div className="text-left">
|
||
<div className="font-medium">Download JSON</div>
|
||
<div className="text-sm opacity-90">Reusable invoice template</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||
<strong>Tip:</strong> Save as JSON to create reusable invoice templates. Load them later using the "Open" tab above.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Usage Tips */}
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md overflow-hidden">
|
||
<div
|
||
onClick={() => setUsageTipsExpanded(!usageTipsExpanded)}
|
||
className="px-4 py-3 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors flex items-center justify-between"
|
||
>
|
||
<h4 className="text-blue-800 dark:text-blue-200 font-medium flex items-center gap-2">
|
||
💡 Usage Tips
|
||
</h4>
|
||
{usageTipsExpanded ? <ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" /> : <ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||
</div>
|
||
|
||
{usageTipsExpanded && (
|
||
<div className="px-4 pb-4 text-blue-700 dark:text-blue-300 text-sm space-y-3">
|
||
<div>
|
||
<p className="font-medium mb-1">📝 Input Methods:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Create New:</strong> Start empty or load sample invoice to explore features</li>
|
||
<li><strong>URL Import:</strong> Fetch invoice data directly from JSON endpoints</li>
|
||
<li><strong>Paste Data:</strong> Auto-detects JSON invoice templates</li>
|
||
<li><strong>Open Files:</strong> Import .json invoice files</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">✏️ Invoice Editing:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Company & Client:</strong> Fill in business details, addresses, and contact info</li>
|
||
<li><strong>Items:</strong> Add products/services with descriptions, quantities, and prices</li>
|
||
<li><strong>Fees & Discounts:</strong> Add additional fees or discounts (fixed or percentage)</li>
|
||
<li><strong>Payment Terms:</strong> Set full payment, installments, or down payment options</li>
|
||
<li><strong>Digital Signature:</strong> Draw or upload signature for professional invoices</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">🎨 Customization:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Settings:</strong> Change color scheme, currency, and display options</li>
|
||
<li><strong>Logo Upload:</strong> Add your company logo for branding</li>
|
||
<li><strong>Payment Methods:</strong> Add bank details, payment links, or QR codes</li>
|
||
<li><strong>Notes & Messages:</strong> Include payment terms, thank you messages, and authorized signatures</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">📤 Export Options:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>PDF:</strong> Generate professional PDF invoices for clients</li>
|
||
<li><strong>JSON:</strong> Save as reusable invoice templates</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">💾 Data Privacy:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Local Processing:</strong> All data stays in your browser</li>
|
||
<li><strong>No Upload:</strong> We don't store or transmit your invoice data</li>
|
||
<li><strong>Secure:</strong> Your business information remains private</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Error Display */}
|
||
{error && (
|
||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||
<div className="flex items-center gap-2">
|
||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||
<p className="text-red-700 dark:text-red-300">{error}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Settings Modal */}
|
||
{showSettingsModal && (
|
||
<InvoiceSettingsModal
|
||
invoiceData={invoiceData}
|
||
currencies={currencies}
|
||
onUpdateSettings={updateSettings}
|
||
onClose={() => setShowSettingsModal(false)}
|
||
logoInputRef={logoInputRef}
|
||
handleLogoUpload={handleLogoUpload}
|
||
removeLogo={removeLogo}
|
||
/>
|
||
)}
|
||
|
||
{/* Confirmation Modal */}
|
||
{showInputChangeModal && (
|
||
<InputChangeConfirmationModal
|
||
invoiceData={invoiceData}
|
||
currentMethod={activeTab}
|
||
newMethod={pendingTabChange}
|
||
onConfirm={confirmInputChange}
|
||
onCancel={cancelInputChange}
|
||
/>
|
||
)}
|
||
|
||
{/* Signature Drawing Modal */}
|
||
<SignaturePadModal
|
||
isOpen={showSignaturePad}
|
||
onClose={() => setShowSignaturePad(false)}
|
||
onSave={(dataURL) => {
|
||
updateInvoiceData('digitalSignature', dataURL);
|
||
setShowSignaturePad(false);
|
||
}}
|
||
/>
|
||
|
||
</div>
|
||
|
||
{/* Related Tools */}
|
||
<RelatedTools toolId="invoice-editor" />
|
||
</ToolLayout>
|
||
</>
|
||
);
|
||
};
|
||
|
||
// Searchable Currency Dropdown Component
|
||
const SearchableCurrencyDropdown = ({ currencies, selectedCurrency, onSelect }) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
|
||
const filteredCurrencies = currencies.filter(currency =>
|
||
currency.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
currency.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
(currency.symbol && currency.symbol.includes(searchTerm))
|
||
);
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white text-left flex items-center justify-between"
|
||
>
|
||
<span>
|
||
{selectedCurrency?.symbol || selectedCurrency?.code || 'USD'} - {selectedCurrency?.name || 'US Dollar'} ({selectedCurrency?.code || 'USD'})
|
||
</span>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
|
||
{isOpen && (
|
||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||
<div className="p-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Search currencies..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white text-sm"
|
||
/>
|
||
</div>
|
||
<div className="max-h-48 overflow-y-auto">
|
||
{filteredCurrencies.map(currency => (
|
||
<button
|
||
key={currency.code}
|
||
type="button"
|
||
onClick={() => {
|
||
onSelect(currency);
|
||
setIsOpen(false);
|
||
setSearchTerm('');
|
||
}}
|
||
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 text-sm dark:text-white"
|
||
>
|
||
{currency.symbol || currency.code} - {currency.name} ({currency.code})
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Invoice Settings Modal Component
|
||
const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClose, logoInputRef, handleLogoUpload, removeLogo }) => {
|
||
const [activeTab, setActiveTab] = useState('general');
|
||
|
||
const updatePaymentMethod = (field, value) => {
|
||
onUpdateSettings('paymentMethod', {
|
||
...invoiceData.paymentMethod,
|
||
[field]: value
|
||
});
|
||
};
|
||
|
||
const updatePaymentMethodNested = (section, field, value) => {
|
||
onUpdateSettings('paymentMethod', {
|
||
...invoiceData.paymentMethod,
|
||
[section]: {
|
||
...invoiceData.paymentMethod[section],
|
||
[field]: value
|
||
}
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||
<div className="px-6 py-4">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Settings</h3>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||
>
|
||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex border-b border-gray-200 dark:border-gray-600">
|
||
<button
|
||
onClick={() => setActiveTab('general')}
|
||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||
activeTab === 'general'
|
||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||
}`}
|
||
>
|
||
General
|
||
{activeTab === 'general' && (
|
||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400"></div>
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('layout')}
|
||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||
activeTab === 'layout'
|
||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||
}`}
|
||
>
|
||
Layout
|
||
{activeTab === 'layout' && (
|
||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400"></div>
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('payment')}
|
||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||
activeTab === 'payment'
|
||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||
}`}
|
||
>
|
||
Payment
|
||
{activeTab === 'payment' && (
|
||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400"></div>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overflow-y-auto max-h-[60vh]">
|
||
{/* General Tab */}
|
||
{activeTab === 'general' && (
|
||
<div className="p-6 space-y-6">
|
||
{/* Logo Upload */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Attach Your Logo
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
{invoiceData.company.logo ? (
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={invoiceData.company.logo}
|
||
alt="Company Logo"
|
||
className="h-12 w-12 object-contain border border-gray-300 rounded-lg"
|
||
/>
|
||
<button
|
||
onClick={removeLogo}
|
||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 border border-red-300 hover:border-red-400 rounded-md transition-colors"
|
||
>
|
||
Remove Logo
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => logoInputRef.current?.click()}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
Attach Logo
|
||
</button>
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={logoInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleLogoUpload}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
|
||
{/* Color Scheme */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Color Scheme
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="color"
|
||
value={invoiceData.settings?.colorScheme || '#3B82F6'}
|
||
onChange={(e) => onUpdateSettings('colorScheme', e.target.value)}
|
||
className="w-12 h-12 rounded-lg border border-gray-300 cursor-pointer bg-transparent"
|
||
/>
|
||
<div>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
This color will be used throughout the invoice and PDF
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Current: {invoiceData.settings?.colorScheme || '#3B82F6'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Currency */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Currency
|
||
</label>
|
||
<SearchableCurrencyDropdown
|
||
currencies={currencies}
|
||
selectedCurrency={invoiceData.settings?.currency}
|
||
onSelect={(currency) => onUpdateSettings('currency', currency)}
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Selected: {invoiceData.settings?.currency?.symbol || invoiceData.settings?.currency?.code || '$'} ({invoiceData.settings?.currency?.code || 'USD'})
|
||
</p>
|
||
</div>
|
||
|
||
{/* Thousand Separator */}
|
||
<div>
|
||
<label className="flex items-center gap-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.thousandSeparator !== false}
|
||
onChange={(e) => onUpdateSettings('thousandSeparator', e.target.checked)}
|
||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||
/>
|
||
<div>
|
||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Use Thousand Separator
|
||
</span>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||
Format numbers like 1,000.00 instead of 1000.00
|
||
</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Decimal Digits */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Decimal Digits
|
||
</label>
|
||
<select
|
||
value={invoiceData.settings?.decimalDigits || 2}
|
||
onChange={(e) => onUpdateSettings('decimalDigits', parseInt(e.target.value))}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value={0}>0 (1000)</option>
|
||
<option value={1}>1 (1000.0)</option>
|
||
<option value={2}>2 (1000.00)</option>
|
||
<option value={3}>3 (1000.000)</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Number of decimal places to display
|
||
</p>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* Layout Tab */}
|
||
{activeTab === 'layout' && (
|
||
<div className="p-6 space-y-6">
|
||
{/* Section Spacing */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Section Spacing
|
||
</label>
|
||
<select
|
||
value={invoiceData.settings?.sectionSpacing || 'normal'}
|
||
onChange={(e) => onUpdateSettings('sectionSpacing', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="compact">Compact (15px spacing)</option>
|
||
<option value="normal">Normal (25px spacing)</option>
|
||
<option value="spacious">Spacious (40px spacing)</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Controls the spacing between major sections for better multi-page layout
|
||
</p>
|
||
</div>
|
||
|
||
{/* Page Break Controls */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Page Break Controls
|
||
</label>
|
||
<div className="space-y-3">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.pageBreaks?.beforePaymentSchedule || false}
|
||
onChange={(e) => onUpdateSettings('pageBreaks', {
|
||
...invoiceData.settings?.pageBreaks,
|
||
beforePaymentSchedule: e.target.checked
|
||
})}
|
||
className="mr-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Force page break before Payment Schedule</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.pageBreaks?.beforeItemsTable || false}
|
||
onChange={(e) => onUpdateSettings('pageBreaks', {
|
||
...invoiceData.settings?.pageBreaks,
|
||
beforeItemsTable: e.target.checked
|
||
})}
|
||
className="mr-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Force page break before Items Table</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.pageBreaks?.beforePaymentMethod || false}
|
||
onChange={(e) => onUpdateSettings('pageBreaks', {
|
||
...invoiceData.settings?.pageBreaks,
|
||
beforePaymentMethod: e.target.checked
|
||
})}
|
||
className="mr-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Force page break before Payment Method</span>
|
||
</label>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
Use page breaks to ensure important sections start on a new page in PDF output
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Payment Methods Tab */}
|
||
{activeTab === 'payment' && (
|
||
<div className="p-6 space-y-6">
|
||
{/* Payment Method Type */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Payment Method Display
|
||
</label>
|
||
<select
|
||
value={invoiceData.paymentMethod?.type || 'none'}
|
||
onChange={(e) => updatePaymentMethod('type', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="none">No Payment Method</option>
|
||
<option value="bank">Bank Details</option>
|
||
<option value="link">Payment Link</option>
|
||
<option value="qr">QR Code</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Choose how payment information appears on your invoice
|
||
</p>
|
||
</div>
|
||
|
||
|
||
{/* Bank Details */}
|
||
{invoiceData.paymentMethod?.type === 'bank' && (
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Bank Details</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Bank Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.bankName || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'bankName', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., Chase Bank"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Account Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.accountName || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'accountName', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., John Doe"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Account Number
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.accountNumber || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'accountNumber', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., 1234567890"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Routing Number
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.routingNumber || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'routingNumber', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., 021000021"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
SWIFT Code
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.swiftCode || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'swiftCode', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., CHASUS33"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
IBAN
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.iban || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'iban', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., GB29 NWBK 6016 1331 9268 19"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Payment Link */}
|
||
{invoiceData.paymentMethod?.type === 'link' && (
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Payment Link</h4>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Payment URL
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={invoiceData.paymentMethod?.paymentLink?.url || ''}
|
||
onChange={(e) => updatePaymentMethodNested('paymentLink', 'url', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="https://pay.stripe.com/..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Button Label
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.paymentLink?.label || 'Pay Online'}
|
||
onChange={(e) => updatePaymentMethodNested('paymentLink', 'label', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="Pay Online"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* QR Code */}
|
||
{invoiceData.paymentMethod?.type === 'qr' && (
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">QR Code Payment</h4>
|
||
|
||
{/* QR Code Type Selection */}
|
||
<div className="mb-3">
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||
QR Code Type
|
||
</label>
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="qrType"
|
||
value="auto"
|
||
checked={invoiceData.paymentMethod?.qrCode?.customImage === undefined}
|
||
onChange={() => updatePaymentMethodNested('qrCode', 'customImage', undefined)}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Auto-generate from URL</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="qrType"
|
||
value="custom"
|
||
checked={invoiceData.paymentMethod?.qrCode?.customImage !== undefined}
|
||
onChange={() => updatePaymentMethodNested('qrCode', 'customImage', 'placeholder')}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Upload custom QR code</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Auto-generate QR Code */}
|
||
{invoiceData.paymentMethod?.qrCode?.customImage === undefined && (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Payment URL
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={invoiceData.paymentMethod?.qrCode?.url || ''}
|
||
onChange={(e) => updatePaymentMethodNested('qrCode', 'url', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="https://pay.stripe.com/..."
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||
QR code will be automatically generated from this URL
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custom QR Code Upload */}
|
||
{invoiceData.paymentMethod?.qrCode?.customImage !== undefined && (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Upload QR Code Image
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
{invoiceData.paymentMethod?.qrCode?.customImage && invoiceData.paymentMethod.qrCode.customImage !== 'placeholder' ? (
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={invoiceData.paymentMethod.qrCode.customImage}
|
||
alt="Custom QR Code"
|
||
className="h-16 w-16 object-contain border border-gray-300 rounded-lg"
|
||
/>
|
||
<button
|
||
onClick={() => updatePaymentMethodNested('qrCode', 'customImage', 'placeholder')}
|
||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 border border-red-300 hover:border-red-400 rounded-md transition-colors"
|
||
>
|
||
Remove QR Code
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => document.getElementById('qr-upload').click()}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
Upload QR Code
|
||
</button>
|
||
)}
|
||
</div>
|
||
<input
|
||
id="qr-upload"
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
updatePaymentMethodNested('qrCode', 'customImage', e.target.result);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
}}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Common QR Code Label */}
|
||
<div className="mt-3">
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
QR Code Label
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.qrCode?.label || 'Scan to Pay'}
|
||
onChange={(e) => updatePaymentMethodNested('qrCode', 'label', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="Scan to Pay"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Payment Status */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Payment Status Stamp
|
||
</label>
|
||
<select
|
||
value={invoiceData.settings?.paymentStatus || ''}
|
||
onChange={(e) => onUpdateSettings('paymentStatus', e.target.value || null)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="">No Status Stamp</option>
|
||
<option value="PAID">PAID</option>
|
||
<option value="PARTIALLY PAID">PARTIALLY PAID</option>
|
||
<option value="UNPAID">UNPAID</option>
|
||
<option value="OVERDUE">OVERDUE</option>
|
||
<option value="PENDING">PENDING</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Add a status stamp to your invoice PDF
|
||
</p>
|
||
</div>
|
||
|
||
{/* Payment Date - only show when PAID is selected */}
|
||
{invoiceData.settings?.paymentStatus === 'PAID' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Payment Date
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={invoiceData.settings?.paymentDate || ''}
|
||
onChange={(e) => onUpdateSettings('paymentDate', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Date when payment was received
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
||
>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Input Change Confirmation Modal Component
|
||
const InputChangeConfirmationModal = ({ invoiceData, currentMethod, newMethod, onConfirm, onCancel }) => {
|
||
const getMethodName = (method) => {
|
||
switch (method) {
|
||
case 'create': return 'Create New';
|
||
case 'create_empty': return 'Start Empty';
|
||
case 'create_sample': return 'Load Sample';
|
||
case 'url': return 'URL Import';
|
||
case 'paste': return 'Paste Data';
|
||
case 'open': return 'File Upload';
|
||
default: return method;
|
||
}
|
||
};
|
||
|
||
const hasItems = invoiceData.items && invoiceData.items.length > 0;
|
||
const hasCompanyInfo = invoiceData.company && invoiceData.company.name;
|
||
const hasClientInfo = invoiceData.client && invoiceData.client.name;
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-amber-50 dark:bg-amber-900/20">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex-shrink-0">
|
||
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-amber-800 dark:text-amber-200">
|
||
Confirm Action
|
||
</h3>
|
||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||
{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.`
|
||
}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-6 py-4">
|
||
<div className="space-y-3">
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
You currently have:
|
||
</p>
|
||
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-1 ml-4">
|
||
{invoiceData.invoiceNumber && (
|
||
<li>• Invoice #{invoiceData.invoiceNumber}</li>
|
||
)}
|
||
{hasCompanyInfo && (
|
||
<li>• Company information ({invoiceData.company.name})</li>
|
||
)}
|
||
{hasClientInfo && (
|
||
<li>• Client information ({invoiceData.client.name})</li>
|
||
)}
|
||
{hasItems && (
|
||
<li>• {invoiceData.items.length} line item{invoiceData.items.length !== 1 ? 's' : ''}</li>
|
||
)}
|
||
</ul>
|
||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||
<strong>Tip:</strong> Consider downloading your current invoice as JSON before proceeding to save your work.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 flex justify-end gap-3">
|
||
<button
|
||
onClick={onCancel}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-md hover:bg-gray-50 dark:hover:bg-gray-500 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={onConfirm}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-md transition-colors flex items-center gap-2"
|
||
>
|
||
<AlertTriangle className="h-4 w-4" />
|
||
Switch & Clear Data
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Signature Drawing Modal Component
|
||
const SignaturePadModal = ({ isOpen, onClose, onSave }) => {
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||
Draw Your Signature
|
||
</h3>
|
||
|
||
<div className="border-2 border-gray-300 dark:border-gray-600 rounded-lg mb-4">
|
||
<canvas
|
||
ref={(canvas) => {
|
||
if (canvas) {
|
||
const ctx = canvas.getContext('2d');
|
||
canvas.width = 400;
|
||
canvas.height = 150;
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
let isDrawing = false;
|
||
let lastX = 0;
|
||
let lastY = 0;
|
||
|
||
const startDrawing = (e) => {
|
||
isDrawing = true;
|
||
const rect = canvas.getBoundingClientRect();
|
||
lastX = e.clientX - rect.left;
|
||
lastY = e.clientY - rect.top;
|
||
};
|
||
|
||
const draw = (e) => {
|
||
if (!isDrawing) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const currentX = e.clientX - rect.left;
|
||
const currentY = e.clientY - rect.top;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(lastX, lastY);
|
||
ctx.lineTo(currentX, currentY);
|
||
ctx.strokeStyle = '#000000';
|
||
ctx.lineWidth = 2;
|
||
ctx.lineCap = 'round';
|
||
ctx.stroke();
|
||
|
||
lastX = currentX;
|
||
lastY = currentY;
|
||
};
|
||
|
||
const stopDrawing = () => {
|
||
isDrawing = false;
|
||
};
|
||
|
||
canvas.addEventListener('mousedown', startDrawing);
|
||
canvas.addEventListener('mousemove', draw);
|
||
canvas.addEventListener('mouseup', stopDrawing);
|
||
canvas.addEventListener('mouseout', stopDrawing);
|
||
}
|
||
}}
|
||
className="w-full h-32 cursor-crosshair"
|
||
style={{ touchAction: 'none' }}
|
||
/>
|
||
</div>
|
||
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||
Draw your signature in the box above using your mouse or touch device.
|
||
</p>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => {
|
||
const canvas = document.querySelector('canvas');
|
||
if (canvas) {
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
}
|
||
}}
|
||
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
Clear
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const canvas = document.querySelector('canvas');
|
||
if (canvas) {
|
||
const dataURL = canvas.toDataURL('image/png');
|
||
onSave(dataURL);
|
||
}
|
||
}}
|
||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||
>
|
||
Save Signature
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default InvoiceEditor;
|