- Updated release notes to use new JSON structure with individual commit timestamps - Removed hash display from release notes for cleaner UI - Fixed automatic recalculation of percentage-based installments in Invoice Editor and Preview - Integrated custom logo.svg in header and footer with cleaner styling - Moved all data files to /public/data/ for better organization - Cleaned up unused release data files and improved file structure
2816 lines
142 KiB
JavaScript
2816 lines
142 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';
|
|
|
|
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);
|
|
|
|
|
|
|
|
// 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').format(num);
|
|
};
|
|
|
|
const formatCurrency = (amount, showSymbol = true) => {
|
|
const currency = invoiceData.settings?.currency || { code: 'USD', symbol: '$' };
|
|
const formattedAmount = formatNumber(amount.toFixed(2));
|
|
|
|
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);
|
|
};
|
|
|
|
const handleFileImport = (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);
|
|
setActiveTab('create');
|
|
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 (
|
|
<ToolLayout
|
|
title="Invoice Editor"
|
|
description="Create, edit, and export professional invoices with PDF generation"
|
|
>
|
|
<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="space-y-4">
|
|
<div className="text-center py-8">
|
|
<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">
|
|
Start Building Your Invoice
|
|
</h3>
|
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
|
Choose how you'd like to begin creating your professional invoice
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
|
<button
|
|
onClick={handleStartEmpty}
|
|
className="group relative overflow-hidden rounded-lg border-2 border-dashed border-blue-300 dark:border-blue-600 p-6 hover:border-blue-400 dark:hover:border-blue-500 transition-colors"
|
|
>
|
|
<div className="flex flex-col items-center">
|
|
<Plus className="h-8 w-8 text-blue-500 mb-3 group-hover:scale-110 transition-transform" />
|
|
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Start Empty</span>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 mt-1">Create a blank invoice</span>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleLoadSample}
|
|
className="group relative overflow-hidden rounded-lg border-2 border-dashed border-green-300 dark:border-green-600 p-6 hover:border-green-400 dark:hover:border-green-500 transition-colors"
|
|
>
|
|
<div className="flex flex-col items-center">
|
|
<FileText className="h-8 w-8 text-green-500 mb-3 group-hover:scale-110 transition-transform" />
|
|
<span className="text-sm font-medium text-green-600 dark:text-green-400">Load Sample</span>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 mt-1">Start with example invoice</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'url' && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Invoice JSON URL
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="https://drive.google.com/file/d/... or any JSON URL"
|
|
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"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleUrlFetch}
|
|
disabled={!url.trim() || isLoading}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
Loading...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Globe className="h-4 w-4" />
|
|
Fetch Invoice Data
|
|
</>
|
|
)}
|
|
</button>
|
|
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
|
<div className="flex-shrink-0">
|
|
<svg className="h-4 w-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
|
<strong>Google Drive:</strong> Use share links like "drive.google.com/file/d/..." - we'll convert them automatically.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'paste' && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Paste Invoice JSON Data
|
|
</label>
|
|
<textarea
|
|
value={inputText}
|
|
onChange={(e) => setInputText(e.target.value)}
|
|
placeholder="Paste your invoice JSON data here..."
|
|
className="w-full h-32 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"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
try {
|
|
const parsed = JSON.parse(inputText);
|
|
setInvoiceData(parsed);
|
|
setCreateNewCompleted(true);
|
|
setError('');
|
|
} catch (err) {
|
|
setError('Invalid JSON format');
|
|
}
|
|
}}
|
|
disabled={!inputText.trim()}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Load Invoice Data
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'open' && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Choose File
|
|
</label>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".json"
|
|
onChange={handleFileImport}
|
|
className="block w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900/20 dark:file:text-blue-300 dark:hover:file:bg-blue-900/30"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
|
<div className="flex-shrink-0">
|
|
<svg className="h-4 w-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
|
</svg>
|
|
</div>
|
|
<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.
|
|
</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-center 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 pr-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 className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
)}
|
|
|
|
{/* 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>
|
|
</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 Methods
|
|
{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>
|
|
</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>
|
|
)}
|
|
</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;
|