Major improvements to Object Editor, Table Editor, and Invoice Editor: ## UX Enhancements: - Made export sections collapsible across all editors to reduce page height - Added comprehensive, collapsible usage tips with eye-catching design - Implemented consistent input method patterns (file auto-load, inline URL buttons) - Paste sections now collapse after successful parsing with data summaries ## Data Loss Prevention: - Added confirmation modals when switching input methods with existing data - Amber-themed warning design with specific data summaries - Suggests saving before proceeding with destructive actions - Prevents accidental data loss across all editor tools ## Consistency Improvements: - Standardized file input styling with 'tool-input' class - URL fetch buttons now inline (not below input) across all editors - Parse buttons positioned consistently on bottom-right - Auto-load behavior for file inputs matching across editors ## Bug Fixes: - Fixed Table Editor cell text overflow with proper truncation - Fixed Object Editor file input to auto-load content - Removed unnecessary parse buttons and checkboxes - Fixed Invoice Editor URL form layout ## Documentation: - Created EDITOR_TOOL_GUIDE.md with comprehensive patterns - Created EDITOR_CHECKLIST.md for quick reference - Created PROJECT_ROADMAP.md with future plans - Created TODO.md with detailed task lists - Documented data loss prevention patterns - Added code examples and best practices All editors now follow consistent UX patterns with improved user experience and data protection.
2979 lines
150 KiB
JavaScript
2979 lines
150 KiB
JavaScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import {
|
||
FileText, Building2, User, Plus, Trash2, Upload, Download,
|
||
Globe, AlertTriangle, ChevronUp, ChevronDown
|
||
} from 'lucide-react';
|
||
import ToolLayout from '../components/ToolLayout';
|
||
import CodeMirrorEditor from '../components/CodeMirrorEditor';
|
||
|
||
const InvoiceEditor = () => {
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
|
||
// State management following TableEditor pattern
|
||
const [activeTab, setActiveTab] = useState('create');
|
||
const [createNewCompleted, setCreateNewCompleted] = useState(false);
|
||
const [showInputChangeModal, setShowInputChangeModal] = useState(false);
|
||
const [pendingTabChange, setPendingTabChange] = useState(null);
|
||
const [inputText, setInputText] = useState('');
|
||
const [url, setUrl] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const fileInputRef = useRef(null);
|
||
const logoInputRef = useRef(null);
|
||
const [pasteCollapsed, setPasteCollapsed] = useState(false);
|
||
const [pasteDataSummary, setPasteDataSummary] = useState(null);
|
||
const [exportExpanded, setExportExpanded] = useState(false);
|
||
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false);
|
||
|
||
|
||
|
||
// Invoice data structure
|
||
const [invoiceData, setInvoiceData] = useState({
|
||
invoiceNumber: '',
|
||
date: new Date().toISOString().split('T')[0],
|
||
dueDate: '',
|
||
company: {
|
||
name: '',
|
||
address: '',
|
||
city: '',
|
||
phone: '',
|
||
email: '',
|
||
logo: null
|
||
},
|
||
client: {
|
||
name: '',
|
||
address: '',
|
||
city: '',
|
||
phone: '',
|
||
email: ''
|
||
},
|
||
items: [],
|
||
fees: [], // Array of {id, label, type: 'fixed'|'percentage', amount, value}
|
||
discounts: [], // Array of {id, label, type: 'fixed'|'percentage', amount, value}
|
||
subtotal: 0,
|
||
discount: 0,
|
||
total: 0,
|
||
notes: '',
|
||
thankYouMessage: '',
|
||
authorizedSignedText: '',
|
||
digitalSignature: null,
|
||
paymentTerms: {
|
||
type: 'full', // 'full', 'installment', 'downpayment'
|
||
downPayment: {
|
||
amount: 0,
|
||
percentage: 0,
|
||
dueDate: ''
|
||
},
|
||
installments: []
|
||
},
|
||
settings: {
|
||
colorScheme: '#3B82F6',
|
||
currency: { code: 'USD', symbol: '$' },
|
||
thousandSeparator: true,
|
||
showFromSection: true
|
||
},
|
||
paymentMethod: {
|
||
type: 'none', // 'none', 'bank', 'link', 'qr'
|
||
bankDetails: {
|
||
bankName: '',
|
||
accountName: '',
|
||
accountNumber: '',
|
||
routingNumber: '',
|
||
swiftCode: '',
|
||
iban: ''
|
||
},
|
||
paymentLink: {
|
||
url: '',
|
||
label: 'Pay Online'
|
||
},
|
||
qrCode: {
|
||
enabled: false,
|
||
customImage: null
|
||
}
|
||
}
|
||
});
|
||
|
||
// Additional state variables
|
||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||
const [showSignaturePad, setShowSignaturePad] = useState(false);
|
||
const [pdfPageSize, setPdfPageSize] = useState('A4'); // A4 or F4
|
||
const [currencies, setCurrencies] = useState([]);
|
||
|
||
// Load currencies from JSON file
|
||
useEffect(() => {
|
||
const loadCurrencies = async () => {
|
||
try {
|
||
const response = await fetch('/data/currencies.json');
|
||
const currencyData = await response.json();
|
||
setCurrencies(currencyData);
|
||
} catch (error) {
|
||
console.error('Failed to load currencies:', error);
|
||
// Fallback to basic currencies
|
||
setCurrencies([
|
||
{ code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' },
|
||
{ code: 'USD', name: 'US Dollar', symbol: '$' },
|
||
{ code: 'EUR', name: 'Euro', symbol: '€' },
|
||
{ code: 'GBP', name: 'British Pound', symbol: '£' },
|
||
{ code: 'JPY', name: 'Japanese Yen', symbol: '¥' }
|
||
]);
|
||
}
|
||
};
|
||
loadCurrencies();
|
||
}, []);
|
||
|
||
// Load saved data on component mount
|
||
useEffect(() => {
|
||
try {
|
||
const savedInvoice = localStorage.getItem('currentInvoice');
|
||
const savedPageSize = localStorage.getItem('pdfPageSize');
|
||
const isEditMode = searchParams.get('mode') === 'edit';
|
||
|
||
if (savedInvoice) {
|
||
const parsedInvoice = JSON.parse(savedInvoice);
|
||
|
||
// Ensure backward compatibility with new fields
|
||
const updatedInvoice = {
|
||
...parsedInvoice,
|
||
fees: parsedInvoice.fees || [],
|
||
discounts: parsedInvoice.discounts || [],
|
||
company: {
|
||
...parsedInvoice.company,
|
||
bankName: parsedInvoice.company?.bankName || '',
|
||
accountName: parsedInvoice.company?.accountName || '',
|
||
accountNumber: parsedInvoice.company?.accountNumber || ''
|
||
}
|
||
};
|
||
setInvoiceData(updatedInvoice);
|
||
|
||
// If we're in edit mode or have saved data, show the editor section
|
||
const hasData = updatedInvoice.invoiceNumber || updatedInvoice.company.name || updatedInvoice.items.length > 0;
|
||
|
||
if (isEditMode || hasData) {
|
||
setCreateNewCompleted(true);
|
||
setActiveTab('create');
|
||
}
|
||
} else if (isEditMode) {
|
||
// Edit mode but no data - redirect back to editor without mode
|
||
window.location.replace('/invoice-editor');
|
||
}
|
||
|
||
if (savedPageSize) {
|
||
setPdfPageSize(savedPageSize);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load saved invoice:', error);
|
||
}
|
||
}, [searchParams]);
|
||
|
||
// Auto-save invoice data to localStorage whenever it changes
|
||
useEffect(() => {
|
||
// Only auto-save if we have meaningful data or if createNewCompleted is true
|
||
if (createNewCompleted || invoiceData.invoiceNumber || invoiceData.company.name || invoiceData.items.length > 0) {
|
||
try {
|
||
localStorage.setItem('currentInvoice', JSON.stringify(invoiceData));
|
||
} catch (error) {
|
||
console.error('Failed to save invoice:', error);
|
||
}
|
||
}
|
||
}, [invoiceData, createNewCompleted]);
|
||
|
||
// Safety recalculation - ensure totals are always correct
|
||
useEffect(() => {
|
||
const { subtotal, total } = calculateTotals(
|
||
invoiceData.items,
|
||
invoiceData.discount,
|
||
invoiceData.fees,
|
||
invoiceData.discounts
|
||
);
|
||
|
||
if (invoiceData.subtotal !== subtotal || invoiceData.total !== total) {
|
||
setInvoiceData(prev => {
|
||
const updated = {
|
||
...prev,
|
||
subtotal,
|
||
total
|
||
};
|
||
|
||
// Recalculate installments when total changes
|
||
if (prev.paymentTerms?.installments?.length > 0) {
|
||
const updatedInstallments = prev.paymentTerms.installments.map(installment => {
|
||
if (installment.type === 'percentage') {
|
||
const baseAmount = prev.paymentTerms?.type === 'downpayment'
|
||
? total - (prev.paymentTerms?.downPayment?.amount || 0)
|
||
: total;
|
||
const amount = (baseAmount * (installment.percentage || 0)) / 100;
|
||
return { ...installment, amount };
|
||
}
|
||
return installment;
|
||
});
|
||
|
||
updated.paymentTerms = {
|
||
...prev.paymentTerms,
|
||
installments: updatedInstallments
|
||
};
|
||
}
|
||
|
||
return updated;
|
||
});
|
||
}
|
||
}, [invoiceData.items, invoiceData.discount, invoiceData.fees, invoiceData.discounts, invoiceData.subtotal, invoiceData.total]);
|
||
|
||
// Save PDF page size to localStorage
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem('pdfPageSize', pdfPageSize);
|
||
} catch (error) {
|
||
console.error('Failed to save PDF page size:', error);
|
||
}
|
||
}, [pdfPageSize]);
|
||
|
||
// Sample invoice data
|
||
const sampleInvoiceData = {
|
||
invoiceNumber: 'INV-2024-001',
|
||
date: '2024-01-15',
|
||
dueDate: '2024-02-15',
|
||
company: {
|
||
name: 'DevTools Inc.',
|
||
address: '123 Tech Street',
|
||
city: 'San Francisco, CA 94105',
|
||
phone: '+1 (555) 123-4567',
|
||
email: 'billing@devtools.com',
|
||
logo: null,
|
||
bankName: 'Chase Bank',
|
||
accountName: 'DevTools Inc.',
|
||
accountNumber: '1234567890'
|
||
},
|
||
client: {
|
||
name: 'Acme Corporation',
|
||
address: '456 Business Ave',
|
||
city: 'New York, NY 10001',
|
||
phone: '+1 (555) 987-6543',
|
||
email: 'accounts@acme.com'
|
||
},
|
||
items: [
|
||
{ id: 1, description: 'Web Development Services', quantity: 40, rate: 125, amount: 5000 },
|
||
{ id: 2, description: 'UI/UX Design', quantity: 20, rate: 100, amount: 2000 },
|
||
{ id: 3, description: 'Project Management', quantity: 10, rate: 150, amount: 1500 }
|
||
],
|
||
fees: [
|
||
{ id: 1, label: 'Processing Fee', type: 'fixed', value: 50, amount: 50 }
|
||
],
|
||
discounts: [
|
||
{ id: 1, label: 'Early Payment Discount', type: 'percentage', value: 5, amount: 425 }
|
||
],
|
||
subtotal: 8500,
|
||
discount: 0,
|
||
total: 8125,
|
||
notes: 'Payment due within 30 days.',
|
||
thankYouMessage: 'Thank you for your business!',
|
||
authorizedSignedText: 'Authorized Signed',
|
||
digitalSignature: null,
|
||
settings: {
|
||
colorScheme: '#3B82F6',
|
||
currency: { code: 'USD', symbol: '$' },
|
||
thousandSeparator: true
|
||
}
|
||
};
|
||
|
||
|
||
// Utility functions for formatting
|
||
const formatNumber = (num, useThousandSeparator = invoiceData.settings?.thousandSeparator) => {
|
||
if (!useThousandSeparator) return num.toString();
|
||
return new Intl.NumberFormat('en-US', { minimumFractionDigits: invoiceData.settings?.decimalDigits ?? 2 }).format(num);
|
||
};
|
||
|
||
const formatCurrency = (amount, showSymbol = true) => {
|
||
const currency = invoiceData.settings?.currency || { code: 'USD', symbol: '$' };
|
||
const decimalDigits = invoiceData.settings?.decimalDigits ?? 2;
|
||
const formattedAmount = formatNumber(amount.toFixed(decimalDigits));
|
||
|
||
if (showSymbol && currency.symbol) {
|
||
return `${currency.symbol} ${formattedAmount}`;
|
||
} else {
|
||
return `${formattedAmount} ${currency.code}`;
|
||
}
|
||
};
|
||
|
||
// Helper functions following TableEditor pattern
|
||
const hasUserData = () => {
|
||
return invoiceData.invoiceNumber ||
|
||
invoiceData.company.name ||
|
||
invoiceData.client.name ||
|
||
invoiceData.items.length > 0;
|
||
};
|
||
|
||
const hasModifiedData = () => {
|
||
if (!hasUserData()) return false;
|
||
|
||
const isSampleData = JSON.stringify(invoiceData) === JSON.stringify(sampleInvoiceData);
|
||
return !isSampleData;
|
||
};
|
||
const clearAllData = () => {
|
||
setInvoiceData({
|
||
invoiceNumber: '',
|
||
date: new Date().toISOString().split('T')[0],
|
||
dueDate: '',
|
||
company: {
|
||
name: '',
|
||
address: '',
|
||
city: '',
|
||
phone: '',
|
||
email: '',
|
||
logo: null,
|
||
bankName: '',
|
||
accountName: '',
|
||
accountNumber: ''
|
||
},
|
||
client: {
|
||
name: '',
|
||
address: '',
|
||
city: '',
|
||
phone: '',
|
||
email: ''
|
||
},
|
||
items: [],
|
||
fees: [],
|
||
discounts: [],
|
||
subtotal: 0,
|
||
discount: 0,
|
||
total: 0,
|
||
notes: '',
|
||
thankYouMessage: '',
|
||
authorizedSignedText: '',
|
||
digitalSignature: null,
|
||
settings: {
|
||
colorScheme: '#3B82F6',
|
||
currency: { code: 'USD', symbol: '$' },
|
||
thousandSeparator: true
|
||
}
|
||
});
|
||
setCreateNewCompleted(false);
|
||
setInputText('');
|
||
setUrl('');
|
||
setError('');
|
||
};
|
||
|
||
// Tab change handling with confirmation
|
||
const handleTabChange = (newTab) => {
|
||
if (newTab === 'create' && activeTab !== 'create') {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange(newTab);
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
setActiveTab(newTab);
|
||
setCreateNewCompleted(false);
|
||
}
|
||
} else if (hasUserData() && activeTab !== newTab) {
|
||
setPendingTabChange(newTab);
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
setActiveTab(newTab);
|
||
if (newTab === 'create' && createNewCompleted) {
|
||
setCreateNewCompleted(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const confirmInputChange = () => {
|
||
if (pendingTabChange === 'create_empty') {
|
||
clearAllData();
|
||
setCreateNewCompleted(true);
|
||
} else if (pendingTabChange === 'create_sample') {
|
||
clearAllData();
|
||
setInvoiceData(sampleInvoiceData);
|
||
setCreateNewCompleted(true);
|
||
} else {
|
||
clearAllData();
|
||
setActiveTab(pendingTabChange);
|
||
if (pendingTabChange === 'create') {
|
||
setCreateNewCompleted(false);
|
||
}
|
||
}
|
||
setShowInputChangeModal(false);
|
||
setPendingTabChange(null);
|
||
};
|
||
|
||
const cancelInputChange = () => {
|
||
setShowInputChangeModal(false);
|
||
setPendingTabChange(null);
|
||
if (activeTab === 'create' && !createNewCompleted) {
|
||
setCreateNewCompleted(true);
|
||
}
|
||
};
|
||
|
||
// Create New button handlers
|
||
const handleStartEmpty = () => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange('create_empty');
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
clearAllData();
|
||
setCreateNewCompleted(true);
|
||
}
|
||
};
|
||
|
||
const handleLoadSample = () => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange('create_sample');
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
setInvoiceData(sampleInvoiceData);
|
||
setCreateNewCompleted(true);
|
||
}
|
||
};
|
||
|
||
// Calculate totals
|
||
const calculateTotals = (items, discount = 0, fees = [], discounts = []) => {
|
||
const subtotal = items.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||
|
||
// Calculate total fees
|
||
const totalFees = fees.reduce((sum, fee) => {
|
||
const feeAmount = fee.type === 'percentage'
|
||
? (subtotal * fee.value) / 100
|
||
: fee.value;
|
||
return sum + feeAmount;
|
||
}, 0);
|
||
|
||
// Calculate total discounts
|
||
const totalDiscounts = discounts.reduce((sum, discountItem) => {
|
||
const discountAmount = discountItem.type === 'percentage'
|
||
? (subtotal * discountItem.value) / 100
|
||
: discountItem.value;
|
||
return sum + discountAmount;
|
||
}, 0);
|
||
|
||
const total = subtotal + totalFees - discount - totalDiscounts;
|
||
return { subtotal, total };
|
||
};
|
||
|
||
// Update invoice data
|
||
const updateInvoiceData = (field, value) => {
|
||
setInvoiceData(prev => {
|
||
const updated = { ...prev, [field]: value };
|
||
|
||
// Recalculate totals if items, discount, fees, or discounts changed
|
||
if (field === 'items' || field === 'discount' || field === 'fees' || field === 'discounts') {
|
||
const { subtotal, total } = calculateTotals(
|
||
field === 'items' ? value : updated.items,
|
||
field === 'discount' ? value : updated.discount,
|
||
field === 'fees' ? value : updated.fees,
|
||
field === 'discounts' ? value : updated.discounts
|
||
);
|
||
updated.subtotal = subtotal;
|
||
updated.total = total;
|
||
}
|
||
|
||
return updated;
|
||
});
|
||
};
|
||
|
||
// Line item management
|
||
const addLineItem = () => {
|
||
const newItem = {
|
||
id: Date.now(),
|
||
description: '',
|
||
quantity: 1,
|
||
rate: 0,
|
||
amount: 0
|
||
};
|
||
updateInvoiceData('items', [...invoiceData.items, newItem]);
|
||
};
|
||
|
||
const updateLineItem = (id, field, value) => {
|
||
const updatedItems = invoiceData.items.map(item => {
|
||
if (item.id === id) {
|
||
const updated = { ...item, [field]: value };
|
||
// Calculate amount when quantity or rate changes
|
||
if (field === 'quantity' || field === 'rate') {
|
||
updated.amount = updated.quantity * updated.rate;
|
||
}
|
||
return updated;
|
||
}
|
||
return item;
|
||
});
|
||
updateInvoiceData('items', updatedItems);
|
||
};
|
||
|
||
const removeLineItem = (id) => {
|
||
updateInvoiceData('items', invoiceData.items.filter(item => item.id !== id));
|
||
};
|
||
|
||
// Move up/down handlers
|
||
const moveItem = (arrayName, id, direction) => {
|
||
const array = invoiceData[arrayName];
|
||
const currentIndex = array.findIndex(item => item.id === id);
|
||
|
||
if (direction === 'up' && currentIndex > 0) {
|
||
const newArray = [...array];
|
||
[newArray[currentIndex], newArray[currentIndex - 1]] = [newArray[currentIndex - 1], newArray[currentIndex]];
|
||
updateInvoiceData(arrayName, newArray);
|
||
} else if (direction === 'down' && currentIndex < array.length - 1) {
|
||
const newArray = [...array];
|
||
[newArray[currentIndex], newArray[currentIndex + 1]] = [newArray[currentIndex + 1], newArray[currentIndex]];
|
||
updateInvoiceData(arrayName, newArray);
|
||
}
|
||
};
|
||
|
||
// Move installments (nested in paymentTerms)
|
||
const moveInstallment = (id, direction) => {
|
||
const installments = invoiceData.paymentTerms?.installments || [];
|
||
const currentIndex = installments.findIndex(item => item.id === id);
|
||
|
||
if (direction === 'up' && currentIndex > 0) {
|
||
const newArray = [...installments];
|
||
[newArray[currentIndex], newArray[currentIndex - 1]] = [newArray[currentIndex - 1], newArray[currentIndex]];
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: newArray
|
||
});
|
||
} else if (direction === 'down' && currentIndex < installments.length - 1) {
|
||
const newArray = [...installments];
|
||
[newArray[currentIndex], newArray[currentIndex + 1]] = [newArray[currentIndex + 1], newArray[currentIndex]];
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: newArray
|
||
});
|
||
}
|
||
};
|
||
|
||
|
||
// Fee management
|
||
const addFee = () => {
|
||
const newFee = {
|
||
id: Date.now(),
|
||
label: '',
|
||
type: 'fixed',
|
||
value: 0,
|
||
amount: 0
|
||
};
|
||
updateInvoiceData('fees', [...(invoiceData.fees || []), newFee]);
|
||
};
|
||
|
||
const updateFee = (id, field, value) => {
|
||
const updatedFees = (invoiceData.fees || []).map(fee => {
|
||
if (fee.id === id) {
|
||
const updatedFee = { ...fee, [field]: value };
|
||
// Calculate amount based on type
|
||
if (field === 'value' || field === 'type') {
|
||
updatedFee.amount = updatedFee.type === 'percentage'
|
||
? (invoiceData.subtotal * updatedFee.value) / 100
|
||
: updatedFee.value;
|
||
}
|
||
return updatedFee;
|
||
}
|
||
return fee;
|
||
});
|
||
updateInvoiceData('fees', updatedFees);
|
||
};
|
||
|
||
const removeFee = (id) => {
|
||
const updatedFees = (invoiceData.fees || []).filter(fee => fee.id !== id);
|
||
updateInvoiceData('fees', updatedFees);
|
||
};
|
||
|
||
// Discount management
|
||
const addDiscount = () => {
|
||
const newDiscount = {
|
||
id: Date.now(),
|
||
label: '',
|
||
type: 'fixed',
|
||
value: 0,
|
||
amount: 0
|
||
};
|
||
updateInvoiceData('discounts', [...(invoiceData.discounts || []), newDiscount]);
|
||
};
|
||
|
||
const updateDiscount = (id, field, value) => {
|
||
const updatedDiscounts = (invoiceData.discounts || []).map(discount => {
|
||
if (discount.id === id) {
|
||
const updatedDiscount = { ...discount, [field]: value };
|
||
// Calculate amount based on type
|
||
if (field === 'value' || field === 'type') {
|
||
updatedDiscount.amount = updatedDiscount.type === 'percentage'
|
||
? (invoiceData.subtotal * updatedDiscount.value) / 100
|
||
: updatedDiscount.value;
|
||
}
|
||
return updatedDiscount;
|
||
}
|
||
return discount;
|
||
});
|
||
updateInvoiceData('discounts', updatedDiscounts);
|
||
};
|
||
|
||
const removeDiscount = (id) => {
|
||
const updatedDiscounts = (invoiceData.discounts || []).filter(discount => discount.id !== id);
|
||
updateInvoiceData('discounts', updatedDiscounts);
|
||
};
|
||
|
||
// Navigate to Invoice Preview for PDF generation
|
||
const handleGeneratePreview = () => {
|
||
try {
|
||
// Save current invoice data to localStorage
|
||
localStorage.setItem('currentInvoice', JSON.stringify(invoiceData));
|
||
localStorage.setItem('pdfPageSize', pdfPageSize);
|
||
|
||
// Navigate to preview page
|
||
navigate('/invoice-preview');
|
||
} catch (error) {
|
||
console.error('Failed to save invoice data:', error);
|
||
alert('Failed to save invoice data. Please try again.');
|
||
}
|
||
};
|
||
|
||
// JSON Export/Import
|
||
const exportJSON = () => {
|
||
const jsonString = JSON.stringify(invoiceData, null, 2);
|
||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `invoice-${invoiceData.invoiceNumber || 'template'}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
// Handle file import (same as Table Editor)
|
||
const handleFileSelect = (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const content = e.target.result;
|
||
const importedData = JSON.parse(content);
|
||
setInvoiceData(importedData);
|
||
setCreateNewCompleted(true);
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Invalid JSON file format');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
};
|
||
|
||
// URL fetching with Google Drive support
|
||
const handleUrlFetch = async () => {
|
||
if (!url.trim()) {
|
||
setError('Please enter a URL');
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setError('');
|
||
|
||
try {
|
||
let fetchUrl = url;
|
||
|
||
// Convert Google Drive share link to direct download link
|
||
if (url.includes('drive.google.com/file/d/')) {
|
||
const fileId = url.match(/\/d\/([a-zA-Z0-9-_]+)/)?.[1];
|
||
if (fileId) {
|
||
fetchUrl = `https://drive.google.com/uc?export=download&id=${fileId}`;
|
||
}
|
||
}
|
||
|
||
const response = await fetch(fetchUrl);
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const text = await response.text();
|
||
const importedData = JSON.parse(text);
|
||
|
||
setInvoiceData(importedData);
|
||
setCreateNewCompleted(true);
|
||
setActiveTab('create');
|
||
setError('');
|
||
} catch (err) {
|
||
setError(`Failed to fetch data: ${err.message}`);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// Logo upload handling
|
||
const handleLogoUpload = (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
updateInvoiceData('company', {
|
||
...invoiceData.company,
|
||
logo: e.target.result
|
||
});
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
const removeLogo = () => {
|
||
updateInvoiceData('company', {
|
||
...invoiceData.company,
|
||
logo: null
|
||
});
|
||
};
|
||
|
||
// Handle signature upload
|
||
const handleSignatureUpload = (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
updateInvoiceData('digitalSignature', e.target.result);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
// Settings update functions
|
||
const updateSettings = (key, value) => {
|
||
if (key === 'company') {
|
||
updateInvoiceData('company', value);
|
||
} else if (key === 'paymentMethod') {
|
||
updateInvoiceData('paymentMethod', value);
|
||
} else {
|
||
updateInvoiceData('settings', {
|
||
...invoiceData.settings,
|
||
[key]: value
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<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="text-center py-12">
|
||
<FileText className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4" />
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
Start Building Your Invoice
|
||
</h3>
|
||
<p className="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||
Choose how you'd like to begin creating your professional invoice
|
||
</p>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<button
|
||
onClick={() => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange('create_empty');
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
handleStartEmpty();
|
||
}
|
||
}}
|
||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors group"
|
||
>
|
||
<Plus className="h-8 w-8 text-gray-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 mb-2" />
|
||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||
Start Empty
|
||
</span>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
||
Create a blank invoice
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => {
|
||
if (hasModifiedData()) {
|
||
setPendingTabChange('create_sample');
|
||
setShowInputChangeModal(true);
|
||
} else {
|
||
handleLoadSample();
|
||
}
|
||
}}
|
||
className="flex flex-col items-center p-6 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors group"
|
||
>
|
||
<FileText className="h-8 w-8 text-gray-400 group-hover:text-green-500 dark:group-hover:text-green-400 mb-2" />
|
||
<span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-green-600 dark:group-hover:text-green-400">
|
||
Load Sample
|
||
</span>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">
|
||
Start with example invoice
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'url' && (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<div className="relative flex-1">
|
||
<input
|
||
type="url"
|
||
value={url}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
placeholder="https://api.telegram.org/bot<token>/getMe"
|
||
className="tool-input w-full"
|
||
onKeyPress={(e) => e.key === 'Enter' && handleUrlFetch()}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={handleUrlFetch}
|
||
disabled={isLoading || !url.trim()}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded-md transition-colors flex items-center whitespace-nowrap"
|
||
>
|
||
{isLoading ? 'Fetching...' : 'Fetch Data'}
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
Enter any URL that returns JSON data. Examples: Telegram Bot API, JSONPlaceholder, GitHub API, etc.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'paste' && (
|
||
pasteCollapsed ? (
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm text-green-700 dark:text-green-300">
|
||
✓ Invoice loaded: {pasteDataSummary.invoiceNumber || 'New Invoice'}
|
||
</span>
|
||
<button
|
||
onClick={() => setPasteCollapsed(false)}
|
||
className="text-sm text-blue-600 hover:underline"
|
||
>
|
||
Edit Input ▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<CodeMirrorEditor
|
||
value={inputText}
|
||
onChange={setInputText}
|
||
placeholder="Paste your invoice JSON data here..."
|
||
language="json"
|
||
maxLines={12}
|
||
showToggle={true}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
{error && (
|
||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||
<p className="text-sm text-red-600 dark:text-red-400">
|
||
<strong>Invalid Data:</strong> {error}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between flex-shrink-0">
|
||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||
Supports JSON invoice templates
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
try {
|
||
const parsed = JSON.parse(inputText);
|
||
setInvoiceData(parsed);
|
||
setCreateNewCompleted(true);
|
||
setPasteDataSummary({
|
||
invoiceNumber: parsed.invoiceNumber || 'New Invoice',
|
||
size: inputText.length
|
||
});
|
||
setPasteCollapsed(true);
|
||
setError('');
|
||
} catch (err) {
|
||
setError('Invalid JSON format: ' + err.message);
|
||
setPasteCollapsed(false);
|
||
}
|
||
}}
|
||
disabled={!inputText.trim()}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-medium px-4 py-2 rounded-md transition-colors flex-shrink-0"
|
||
>
|
||
Load Invoice
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{activeTab === 'open' && (
|
||
<div className="space-y-3">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".json"
|
||
onChange={handleFileSelect}
|
||
className="tool-input"
|
||
/>
|
||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||
<p className="text-xs text-green-700 dark:text-green-300">
|
||
🔒 <strong>Privacy:</strong> Your data stays in your browser. We don't store or upload anything - just help you open, edit, and export your files locally.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Main Editor Section */}
|
||
{ (
|
||
<div className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||
<div className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Editor</h2>
|
||
</div>
|
||
{(
|
||
<button
|
||
onClick={() => setShowSettingsModal(true)}
|
||
className="flex items-center gap-2 px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md transition-colors"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
Settings
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||
{!createNewCompleted ? (
|
||
<div className="text-center py-12">
|
||
<FileText className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No Invoice Data Loaded</h3>
|
||
<p className="text-gray-500 dark:text-gray-400">
|
||
Use the input section above to create a new invoice or load existing data.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-8">
|
||
{/* Invoice Header - Like Final Invoice */}
|
||
<div className="text-white rounded-xl p-6 border border-blue-200 dark:border-blue-800" style={{ background: `linear-gradient(to right, ${invoiceData.settings?.colorScheme || '#3B82F6'}, ${invoiceData.settings?.colorScheme || '#3B82F6'}dd)` }}>
|
||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4">
|
||
{/* Logo and Company Name */}
|
||
<div className="flex items-center gap-4">
|
||
{invoiceData.company.logo ? (
|
||
<img
|
||
src={invoiceData.company.logo}
|
||
alt="Company Logo"
|
||
className="h-12 w-12 object-contain bg-white rounded-lg p-1"
|
||
/>
|
||
) : (
|
||
<div className="h-12 w-12 bg-white/20 rounded-lg flex items-center justify-center">
|
||
<Building2 className="h-6 w-6 text-white" />
|
||
</div>
|
||
)}
|
||
<div>
|
||
<h1 className="text-3xl font-bold">INVOICE</h1>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.company.name}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, name: e.target.value })}
|
||
className="bg-transparent border-0 text-white placeholder-white/70 text-sm font-medium focus:outline-none focus:ring-0 p-0"
|
||
placeholder="Your Company Name"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Invoice Details */}
|
||
<div className="text-right space-y-1 flex flex-col items-end">
|
||
<div className="flex items-center gap-2 w-full">
|
||
<span className="text-sm font-medium">#</span>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.invoiceNumber}
|
||
onChange={(e) => updateInvoiceData('invoiceNumber', e.target.value)}
|
||
className="bg-white/20 border border-white/30 text-white placeholder-white/70 text-sm font-medium rounded px-2 py-1 w-32 text-left sm:text-right focus:outline-none focus:ring-1 focus:ring-white/50 w-full"
|
||
placeholder="INV-001"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm w-full">
|
||
<span>Date:</span>
|
||
<input
|
||
type="date"
|
||
value={invoiceData.date}
|
||
onChange={(e) => updateInvoiceData('date', e.target.value)}
|
||
className="bg-white/20 border border-white/30 text-white text-xs rounded px-2 py-1 w-36 focus:outline-none focus:ring-1 focus:ring-white/50 w-full"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm w-full">
|
||
<span>Due:</span>
|
||
<input
|
||
type="date"
|
||
value={invoiceData.dueDate}
|
||
onChange={(e) => updateInvoiceData('dueDate', e.target.value)}
|
||
className="bg-white/20 border border-white/30 text-white text-xs rounded px-2 py-1 w-36 focus:outline-none focus:ring-1 focus:ring-white/50 w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* From and To Section - Invoice Layout Style */}
|
||
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-6">
|
||
{/* From Section */}
|
||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-8 w-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}20`, color: invoiceData.settings?.colorScheme || '#3B82F6' }}>
|
||
<Building2 className="h-4 w-4" />
|
||
</div>
|
||
<h3 className="text-sm font-bold uppercase tracking-wide" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6' }}>FROM</h3>
|
||
</div>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.showFromSection ?? true}
|
||
onChange={(e) => updateInvoiceData('settings', {
|
||
...invoiceData.settings,
|
||
showFromSection: e.target.checked
|
||
})}
|
||
className="w-4 h-4 rounded border-gray-300 focus:ring-2"
|
||
style={{
|
||
accentColor: invoiceData.settings?.colorScheme || '#3B82F6',
|
||
'--tw-ring-color': `${invoiceData.settings?.colorScheme || '#3B82F6'}40`
|
||
}}
|
||
/>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">Show in preview</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className={`space-y-3 ${!(invoiceData.settings?.showFromSection ?? true) ? 'opacity-50 pointer-events-none' : ''}`}>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.company.name}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, name: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="w-full text-lg font-semibold text-gray-900 dark:text-white bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
style={{ '--focus-color': invoiceData.colors?.from || '#3B82F6' }}
|
||
onFocus={(e) => e.target.style.borderBottomColor = invoiceData.colors?.from || '#3B82F6'}
|
||
onBlur={(e) => e.target.style.borderBottomColor = ''}
|
||
placeholder="Company Name"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.company.address}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, address: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="w-full text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
placeholder="Street Address"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.company.city}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, city: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="w-full text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
placeholder="City, State ZIP"
|
||
/>
|
||
<div className="flex gap-3 flex-col sm:flex-row">
|
||
<input
|
||
type="tel"
|
||
value={invoiceData.company.phone}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, phone: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
placeholder="Phone"
|
||
/>
|
||
<span className="text-gray-400 text-sm self-end pb-1 hidden sm:block">|</span>
|
||
<input
|
||
type="email"
|
||
value={invoiceData.company.email}
|
||
onChange={(e) => updateInvoiceData('company', { ...invoiceData.company, email: e.target.value })}
|
||
disabled={!(invoiceData.settings?.showFromSection ?? true)}
|
||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-blue-500 focus:outline-none focus:ring-0 pb-1 disabled:cursor-not-allowed"
|
||
placeholder="Email"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* To Section */}
|
||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<div className="h-8 w-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}20`, color: invoiceData.settings?.colorScheme || '#3B82F6' }}>
|
||
<User className="h-4 w-4" />
|
||
</div>
|
||
<h3 className="text-sm font-bold uppercase tracking-wide" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6' }}>TO</h3>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<input
|
||
type="text"
|
||
value={invoiceData.client.name}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, name: e.target.value })}
|
||
className="w-full text-lg font-semibold text-gray-900 dark:text-white bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="Client Name"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.client.address}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, address: e.target.value })}
|
||
className="w-full text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="Street Address"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.client.city}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, city: e.target.value })}
|
||
className="w-full text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="City, State ZIP"
|
||
/>
|
||
<div className="flex gap-3 flex-col sm:flex-row">
|
||
<input
|
||
type="tel"
|
||
value={invoiceData.client.phone}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, phone: e.target.value })}
|
||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="Phone"
|
||
/>
|
||
<span className="text-gray-400 text-sm self-end pb-1 hidden sm:block">|</span>
|
||
<input
|
||
type="email"
|
||
value={invoiceData.client.email}
|
||
onChange={(e) => updateInvoiceData('client', { ...invoiceData.client, email: e.target.value })}
|
||
className="flex-1 text-sm text-gray-600 dark:text-gray-300 bg-transparent border-0 border-b border-gray-200 dark:border-gray-600 focus:border-green-500 focus:outline-none focus:ring-0 pb-1"
|
||
placeholder="Email"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Items */}
|
||
<div className="rounded-xl border" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}10`, borderColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}40` }}>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}20`, borderBottomColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}40` }}>
|
||
<th className="px-4 py-3 text-left text-sm font-semibold" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6' }}>Items</th>
|
||
<th className="px-3 py-3 text-center text-sm font-semibold" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6', width: 'auto' }}>Qty</th>
|
||
<th className="px-3 py-3 text-center text-sm font-semibold" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6', width: 'auto' }}>Rate</th>
|
||
<th className="px-3 py-3 text-right text-sm font-semibold" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6', width: 'auto' }}>Amount</th>
|
||
<th className="px-2 py-3 text-center w-12"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white dark:bg-gray-800">
|
||
{invoiceData.items.map((item, index) => (
|
||
<tr key={item.id} className="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||
<td className="px-4 py-3 min-w-[225px]">
|
||
<input
|
||
type="text"
|
||
value={item.description}
|
||
onChange={(e) => updateLineItem(item.id, 'description', e.target.value)}
|
||
className="w-full px-2 py-1 border-0 bg-transparent focus:outline-none focus:ring-0 focus:bg-blue-50 dark:focus:bg-blue-900/20 rounded text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-colors"
|
||
placeholder="Enter item description..."
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-3 text-center" style={{ width: 'auto' }}>
|
||
<input
|
||
type="text"
|
||
value={formatNumber(item.quantity)}
|
||
onChange={(e) => {
|
||
const numValue = parseFloat(e.target.value.replace(/,/g, '')) || 0;
|
||
updateLineItem(item.id, 'quantity', numValue);
|
||
}}
|
||
className="w-full px-2 py-1 text-center border-0 bg-transparent focus:outline-none focus:ring-0 focus:bg-blue-50 dark:focus:bg-blue-900/20 rounded text-gray-900 dark:text-white transition-colors"
|
||
style={{ width: `${Math.max(formatNumber(item.quantity).length * 8 + 20, 60)}px` }}
|
||
onFocus={(e) => {
|
||
// Show raw number on focus
|
||
e.target.value = item.quantity.toString();
|
||
}}
|
||
onBlur={(e) => {
|
||
// Format with thousand separator on blur
|
||
e.target.value = formatNumber(parseFloat(e.target.value.replace(/,/g, '')) || 0);
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-3 text-center" style={{ width: 'auto' }}>
|
||
<div className="relative flex justify-end items-center">
|
||
<span className="text-gray-500 dark:text-gray-400 px-2 py-1 text-xs rounded-1 bg-gray-100 dark:bg-gray-900/20">{invoiceData.settings?.currency?.symbol || '$'}</span>
|
||
<input
|
||
type="text"
|
||
value={formatNumber(item.rate)}
|
||
onChange={(e) => {
|
||
const numValue = parseFloat(e.target.value.replace(/,/g, '')) || 0;
|
||
updateLineItem(item.id, 'rate', numValue);
|
||
}}
|
||
className="pl-2 py-1 text-center border-0 bg-transparent focus:outline-none focus:ring-0 focus:bg-blue-50 dark:focus:bg-blue-900/20 rounded text-gray-900 dark:text-white transition-colors"
|
||
style={{ width: `${Math.max(formatNumber(item.rate).length * 8 + 20, 40)}px` }}
|
||
onFocus={(e) => {
|
||
// Show raw number on focus
|
||
e.target.value = item.rate.toString();
|
||
}}
|
||
onBlur={(e) => {
|
||
// Format with thousand separator on blur
|
||
e.target.value = formatNumber(parseFloat(e.target.value.replace(/,/g, '')) || 0);
|
||
}}
|
||
/>
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-3 text-right font-semibold text-gray-900 dark:text-white" style={{ width: 'auto' }}>
|
||
<span style={{ minWidth: `${Math.max(formatCurrency(item.amount, true).length * 8 + 20, 100)}px`, display: 'inline-block' }}>
|
||
{formatCurrency(item.amount, true)}
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-3 text-center">
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => moveItem('items', item.id, 'up')}
|
||
disabled={index === 0}
|
||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move up"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => moveItem('items', item.id, 'down')}
|
||
disabled={index === invoiceData.items.length - 1}
|
||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move down"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => removeLineItem(item.id)}
|
||
className="p-1 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||
title="Delete item"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{/* Add Item Row */}
|
||
<tr className="border-b border-gray-100 dark:border-gray-700 transition-colors" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}05` }}>
|
||
<td colSpan="5" className="px-4 py-3 relative">
|
||
<button
|
||
onClick={addLineItem}
|
||
className="text-left flex items-center gap-2 py-1 transition-colors sticky left-4"
|
||
style={{
|
||
color: invoiceData.settings?.colorScheme || '#3B82F6',
|
||
':hover': { color: `${invoiceData.settings?.colorScheme || '#3B82F6'}dd` }
|
||
}}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Add item
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Totals and Tax */}
|
||
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-6">
|
||
<div className="rounded-xl p-6 border" style={{ backgroundColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}10`, borderColor: `${invoiceData.settings?.colorScheme || '#3B82F6'}40` }}>
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: invoiceData.settings?.colorScheme || '#3B82F6' }}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||
</svg>
|
||
Fees & Discounts
|
||
</h3>
|
||
|
||
|
||
{/* Fees Section */}
|
||
<div className="mb-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Additional Fees</label>
|
||
<button
|
||
onClick={addFee}
|
||
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300 rounded-md transition-colors"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
Add Fee
|
||
</button>
|
||
</div>
|
||
{(invoiceData.fees || []).map((fee, index) => (
|
||
<div key={fee.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col xl:flex-row gap-2">
|
||
{/* Fee Name */}
|
||
<input
|
||
type="text"
|
||
value={fee.label}
|
||
onChange={(e) => updateFee(fee.id, 'label', e.target.value)}
|
||
placeholder="Fee name"
|
||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||
/>
|
||
|
||
{/* Controls Row */}
|
||
<div className="flex flex-wrap gap-2 flex-col xl:flex-row">
|
||
<input
|
||
type="number"
|
||
value={fee.value}
|
||
onChange={(e) => updateFee(fee.id, 'value', parseFloat(e.target.value) || 0)}
|
||
placeholder="0"
|
||
className="flex-1 xl:flex-0 xl:w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
min="0"
|
||
step="0.01"
|
||
/>
|
||
<select
|
||
value={fee.type}
|
||
onChange={(e) => updateFee(fee.id, 'type', e.target.value)}
|
||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="fixed">Fixed</option>
|
||
<option value="percentage">%</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons - Always on Right */}
|
||
<div className="flex flex-col xl:flex-row gap-1 items-center justify-center">
|
||
<button
|
||
onClick={() => moveItem('fees', fee.id, 'up')}
|
||
disabled={index === 0}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move up"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => moveItem('fees', fee.id, 'down')}
|
||
disabled={index === (invoiceData.fees || []).length - 1}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move down"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => removeFee(fee.id)}
|
||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||
title="Delete fee"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Discounts Section */}
|
||
<div className="mb-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Discounts</label>
|
||
<button
|
||
onClick={addDiscount}
|
||
className="flex items-center gap-1 px-2 py-1 text-xs bg-green-100 hover:bg-green-200 dark:bg-green-900 dark:hover:bg-green-800 text-green-700 dark:text-green-300 rounded-md transition-colors"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
Add Discount
|
||
</button>
|
||
</div>
|
||
{(invoiceData.discounts || []).map((discount, index) => (
|
||
<div key={discount.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col xl:flex-row gap-2">
|
||
{/* Discount Name */}
|
||
<input
|
||
type="text"
|
||
value={discount.label}
|
||
onChange={(e) => updateDiscount(discount.id, 'label', e.target.value)}
|
||
placeholder="Discount name"
|
||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||
/>
|
||
|
||
{/* Controls Row */}
|
||
<div className="flex flex-wrap gap-2 flex-col xl:flex-row">
|
||
<input
|
||
type="number"
|
||
value={discount.value}
|
||
onChange={(e) => updateDiscount(discount.id, 'value', parseFloat(e.target.value) || 0)}
|
||
placeholder="0"
|
||
className="flex-1 xl:flex-0 xl:w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
min="0"
|
||
step="0.01"
|
||
/>
|
||
<select
|
||
value={discount.type}
|
||
onChange={(e) => updateDiscount(discount.id, 'type', e.target.value)}
|
||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="fixed">Fixed</option>
|
||
<option value="percentage">%</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons - Always on Right */}
|
||
<div className="flex flex-col xl:flex-row gap-1 items-center justify-center">
|
||
<button
|
||
onClick={() => moveItem('discounts', discount.id, 'up')}
|
||
disabled={index === 0}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move up"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => moveItem('discounts', discount.id, 'down')}
|
||
disabled={index === (invoiceData.discounts || []).length - 1}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move down"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => removeDiscount(discount.id)}
|
||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||
title="Delete discount"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 rounded-xl p-6 border border-emerald-200 dark:border-emerald-800">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<svg className="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||
</svg>
|
||
Invoice Total
|
||
</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||
<span className="text-gray-600 dark:text-gray-400">Subtotal:</span>
|
||
<span className="font-medium text-gray-900 dark:text-white flex-shrink-0">{formatCurrency(invoiceData.subtotal, true)}</span>
|
||
</div>
|
||
|
||
{/* Dynamic Fees */}
|
||
{(invoiceData.fees || []).map((fee) => (
|
||
<div key={fee.id} className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||
<span className="text-gray-600 dark:text-gray-400">
|
||
{fee.label || 'Fee'} {fee.type === 'percentage' ? `(${fee.value}%)` : ''}:
|
||
</span>
|
||
<span className="font-medium text-blue-600 dark:text-blue-400 flex-shrink-0">
|
||
+{formatCurrency(fee.type === 'percentage' ? (invoiceData.subtotal * fee.value) / 100 : fee.value, true)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Dynamic Discounts */}
|
||
{(invoiceData.discounts || []).map((discount) => (
|
||
<div key={discount.id} className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||
<span className="text-gray-600 dark:text-gray-400">
|
||
{discount.label || 'Discount'} {discount.type === 'percentage' ? `(${discount.value}%)` : ''}:
|
||
</span>
|
||
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">
|
||
-{formatCurrency(discount.type === 'percentage' ? (invoiceData.subtotal * discount.value) / 100 : discount.value, true)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Legacy Discount */}
|
||
{invoiceData.discount > 0 && (
|
||
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||
<span className="text-gray-600 dark:text-gray-400">Discount:</span>
|
||
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">-{formatCurrency(invoiceData.discount, true)}</span>
|
||
</div>
|
||
)}
|
||
<div className="bg-emerald-100 dark:bg-emerald-900/30 rounded-lg p-4 mt-4">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-lg font-semibold text-emerald-800 dark:text-emerald-200">Total:</span>
|
||
<span className="text-2xl font-bold text-emerald-800 dark:text-emerald-200 flex-shrink-0">{formatCurrency(invoiceData.total, true)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Payment Terms - Optional Section */}
|
||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<svg className="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||
</svg>
|
||
Payment Terms (Optional)
|
||
</h3>
|
||
|
||
<div className="space-y-4">
|
||
{/* Payment Type Selection */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Payment Type
|
||
</label>
|
||
<select
|
||
value={invoiceData.paymentTerms?.type || 'full'}
|
||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
type: e.target.value
|
||
})}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="full">Full Payment</option>
|
||
<option value="downpayment">Down Payment + Balance</option>
|
||
<option value="installment">Installments Only</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Down Payment Section */}
|
||
{(invoiceData.paymentTerms?.type === 'downpayment') && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
|
||
<h4 className="text-md font-medium text-gray-900 dark:text-white mb-3">Down Payment</h4>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col lg:flex-row gap-2">
|
||
{/* Down Payment Label */}
|
||
<input
|
||
type="text"
|
||
value="Down Payment"
|
||
readOnly
|
||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white bg-gray-100 dark:bg-gray-600 font-medium"
|
||
/>
|
||
|
||
{/* Controls Row */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<input
|
||
type="number"
|
||
value={
|
||
invoiceData.paymentTerms?.downPayment?.type === 'fixed'
|
||
? (invoiceData.paymentTerms?.downPayment?.amount || 0)
|
||
: (invoiceData.paymentTerms?.downPayment?.percentage || 0)
|
||
}
|
||
onChange={(e) => {
|
||
const value = parseFloat(e.target.value) || 0;
|
||
const isFixed = invoiceData.paymentTerms?.downPayment?.type === 'fixed';
|
||
|
||
let amount, percentage;
|
||
if (isFixed) {
|
||
amount = value;
|
||
percentage = invoiceData.total > 0 ? (amount / invoiceData.total) * 100 : 0;
|
||
} else {
|
||
percentage = value;
|
||
amount = (invoiceData.total * percentage) / 100;
|
||
}
|
||
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
downPayment: {
|
||
...invoiceData.paymentTerms.downPayment,
|
||
percentage,
|
||
amount
|
||
}
|
||
});
|
||
}}
|
||
placeholder="0"
|
||
className="flex-1 lg:flex-0 w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
min="0"
|
||
max="100"
|
||
step="0.01"
|
||
/>
|
||
<select
|
||
value={invoiceData.paymentTerms?.downPayment?.type || 'percentage'}
|
||
onChange={(e) => {
|
||
const type = e.target.value;
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
downPayment: {
|
||
...invoiceData.paymentTerms.downPayment,
|
||
type
|
||
}
|
||
});
|
||
}}
|
||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="percentage">%</option>
|
||
<option value="fixed">Fixed</option>
|
||
</select>
|
||
<input
|
||
type="date"
|
||
value={invoiceData.paymentTerms?.downPayment?.dueDate || ''}
|
||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
downPayment: {
|
||
...invoiceData.paymentTerms.downPayment,
|
||
dueDate: e.target.value
|
||
}
|
||
})}
|
||
className="flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
/>
|
||
<select
|
||
value={invoiceData.paymentTerms?.downPayment?.status || 'pending'}
|
||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
downPayment: {
|
||
...invoiceData.paymentTerms.downPayment,
|
||
status: e.target.value
|
||
}
|
||
})}
|
||
className={`flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white ${
|
||
invoiceData.paymentTerms?.downPayment?.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
|
||
invoiceData.paymentTerms?.downPayment?.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
|
||
invoiceData.paymentTerms?.downPayment?.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
|
||
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
<option value="pending">Pending</option>
|
||
<option value="current">Current</option>
|
||
<option value="paid">Paid</option>
|
||
<option value="overdue">Overdue</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{invoiceData.paymentTerms?.downPayment?.amount > 0 && (
|
||
<div className="mt-3 p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-sm text-blue-800 dark:text-blue-200">
|
||
Remaining Balance: {formatCurrency(invoiceData.total - (invoiceData.paymentTerms?.downPayment?.amount || 0), true)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Installments Section */}
|
||
{(invoiceData.paymentTerms?.type === 'installment' || invoiceData.paymentTerms?.type === 'downpayment') && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
|
||
<div className="flex justify-between items-center mb-3">
|
||
<h4 className="text-md font-medium text-gray-900 dark:text-white">
|
||
{invoiceData.paymentTerms?.type === 'downpayment' ? 'Remaining Balance Installments' : 'Payment Installments'}
|
||
</h4>
|
||
<button
|
||
onClick={() => {
|
||
const newInstallment = {
|
||
id: Date.now(),
|
||
amount: 0,
|
||
percentage: 0,
|
||
dueDate: '',
|
||
description: `Installment ${(invoiceData.paymentTerms?.installments?.length || 0) + 1}`
|
||
};
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: [...(invoiceData.paymentTerms?.installments || []), newInstallment]
|
||
});
|
||
}}
|
||
className="flex items-center gap-1 px-2 py-1 text-xs bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900 dark:hover:bg-indigo-800 text-indigo-700 dark:text-indigo-300 rounded-md transition-colors whitespace-nowrap"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
Add Term
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{(invoiceData.paymentTerms?.installments || []).map((installment, index) => (
|
||
<div key={installment.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col lg:flex-row gap-2">
|
||
{/* Installment Name - Full Width */}
|
||
<input
|
||
type="text"
|
||
value={installment.description}
|
||
onChange={(e) => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, description: e.target.value } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
placeholder="Installment name"
|
||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||
/>
|
||
|
||
{/* Controls Row */}
|
||
<div className="flex flex-wrap gap-2">
|
||
<input
|
||
type="number"
|
||
value={installment.type === 'percentage' ? (installment.percentage || 0) : (installment.amount || 0)}
|
||
onChange={(e) => {
|
||
const value = parseFloat(e.target.value) || 0;
|
||
const baseAmount = invoiceData.paymentTerms?.type === 'downpayment'
|
||
? invoiceData.total - (invoiceData.paymentTerms?.downPayment?.amount || 0)
|
||
: invoiceData.total;
|
||
|
||
let amount, percentage;
|
||
if (installment.type === 'percentage') {
|
||
percentage = value;
|
||
amount = (baseAmount * percentage) / 100;
|
||
} else {
|
||
amount = value;
|
||
percentage = baseAmount > 0 ? (amount / baseAmount) * 100 : 0;
|
||
}
|
||
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, amount, percentage } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
placeholder="0"
|
||
className="flex-1 lg:flex-0 w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
min="0"
|
||
step="0.01"
|
||
/>
|
||
<select
|
||
value={installment.type || 'fixed'}
|
||
onChange={(e) => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, type: e.target.value } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="fixed">Fixed</option>
|
||
<option value="percentage">%</option>
|
||
</select>
|
||
<input
|
||
type="date"
|
||
value={installment.dueDate}
|
||
onChange={(e) => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, dueDate: e.target.value } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
className="flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
/>
|
||
<select
|
||
value={installment.status || 'pending'}
|
||
onChange={(e) => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||
inst.id === installment.id ? { ...inst, status: e.target.value } : inst
|
||
);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
className={`flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white ${
|
||
installment.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
|
||
installment.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
|
||
installment.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
|
||
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
<option value="pending">Pending</option>
|
||
<option value="current">Current</option>
|
||
<option value="paid">Paid</option>
|
||
<option value="overdue">Overdue</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action Buttons - Always on Right */}
|
||
<div className="flex flex-col lg:flex-row gap-1 items-center justify-center">
|
||
<button
|
||
onClick={() => moveInstallment(installment.id, 'up')}
|
||
disabled={index === 0}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move up"
|
||
>
|
||
<ChevronUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => moveInstallment(installment.id, 'down')}
|
||
disabled={index === (invoiceData.paymentTerms?.installments || []).length - 1}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
title="Move down"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const updatedInstallments = invoiceData.paymentTerms.installments.filter(inst => inst.id !== installment.id);
|
||
updateInvoiceData('paymentTerms', {
|
||
...invoiceData.paymentTerms,
|
||
installments: updatedInstallments
|
||
});
|
||
}}
|
||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors mt-12 lg:mt-0"
|
||
title="Delete installment"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Additional Notes & Signature - Independent Card */}
|
||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-xl p-6 border border-amber-200 dark:border-amber-800">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||
<svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
Additional Notes & Signature
|
||
</h3>
|
||
|
||
<div className="space-y-4">
|
||
{/* Additional Notes */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Additional Notes
|
||
</label>
|
||
<textarea
|
||
value={invoiceData.notes}
|
||
onChange={(e) => updateInvoiceData('notes', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-amber-500 focus:border-amber-500 dark:bg-gray-700 dark:text-white resize-none"
|
||
rows="3"
|
||
placeholder="Payment terms, special instructions..."
|
||
/>
|
||
</div>
|
||
|
||
{/* Thank You Message */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Thank You Message
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.thankYouMessage}
|
||
onChange={(e) => updateInvoiceData('thankYouMessage', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-amber-500 focus:border-amber-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="Thank you for your business!"
|
||
/>
|
||
</div>
|
||
|
||
{/* Authorized Signature Text */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Signature Label
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.authorizedSignedText}
|
||
onChange={(e) => updateInvoiceData('authorizedSignedText', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-amber-500 focus:border-amber-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="Authorized Signed"
|
||
/>
|
||
</div>
|
||
|
||
{/* Digital Signature Upload */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
Digital Signature
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
{invoiceData.digitalSignature ? (
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={invoiceData.digitalSignature}
|
||
alt="Digital Signature"
|
||
className="h-12 w-auto object-contain border border-gray-300 rounded-lg bg-white p-1"
|
||
/>
|
||
<button
|
||
onClick={() => updateInvoiceData('digitalSignature', null)}
|
||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 border border-red-300 hover:border-red-400 rounded-md transition-colors"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => document.getElementById('signature-upload').click()}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
Upload Image
|
||
</button>
|
||
<button
|
||
onClick={() => setShowSignaturePad(true)}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||
</svg>
|
||
Draw Signature
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<input
|
||
id="signature-upload"
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleSignatureUpload}
|
||
className="hidden"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Upload an image of your signature (PNG, JPG recommended)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Export Section */}
|
||
{(activeTab !== 'create' || createNewCompleted) && createNewCompleted && (
|
||
<div className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||
<div
|
||
onClick={() => setExportExpanded(!exportExpanded)}
|
||
className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Download className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Export Invoice</h2>
|
||
{exportExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{exportExpanded && (
|
||
<div className="p-4 sm:p-6">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<button
|
||
onClick={handleGeneratePreview}
|
||
className="flex items-center justify-center gap-3 px-6 py-4 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||
>
|
||
<FileText className="h-5 w-5" />
|
||
<div className="text-left">
|
||
<div className="font-medium">Generate PDF</div>
|
||
<div className="text-sm opacity-90">Preview & download PDF</div>
|
||
</div>
|
||
</button>
|
||
|
||
<button
|
||
onClick={exportJSON}
|
||
className="flex items-center justify-center gap-3 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
<Download className="h-5 w-5" />
|
||
<div className="text-left">
|
||
<div className="font-medium">Download JSON</div>
|
||
<div className="text-sm opacity-90">Reusable invoice template</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||
<strong>Tip:</strong> Save as JSON to create reusable invoice templates. Load them later using the "Open" tab above.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Usage Tips */}
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md overflow-hidden">
|
||
<div
|
||
onClick={() => setUsageTipsExpanded(!usageTipsExpanded)}
|
||
className="px-4 py-3 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors flex items-center justify-between"
|
||
>
|
||
<h4 className="text-blue-800 dark:text-blue-200 font-medium flex items-center gap-2">
|
||
💡 Usage Tips
|
||
</h4>
|
||
{usageTipsExpanded ? <ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" /> : <ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||
</div>
|
||
|
||
{usageTipsExpanded && (
|
||
<div className="px-4 pb-4 text-blue-700 dark:text-blue-300 text-sm space-y-3">
|
||
<div>
|
||
<p className="font-medium mb-1">📝 Input Methods:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Create New:</strong> Start empty or load sample invoice to explore features</li>
|
||
<li><strong>URL Import:</strong> Fetch invoice data directly from JSON endpoints</li>
|
||
<li><strong>Paste Data:</strong> Auto-detects JSON invoice templates</li>
|
||
<li><strong>Open Files:</strong> Import .json invoice files</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">✏️ Invoice Editing:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Company & Client:</strong> Fill in business details, addresses, and contact info</li>
|
||
<li><strong>Items:</strong> Add products/services with descriptions, quantities, and prices</li>
|
||
<li><strong>Fees & Discounts:</strong> Add additional fees or discounts (fixed or percentage)</li>
|
||
<li><strong>Payment Terms:</strong> Set full payment, installments, or down payment options</li>
|
||
<li><strong>Digital Signature:</strong> Draw or upload signature for professional invoices</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">🎨 Customization:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Settings:</strong> Change color scheme, currency, and display options</li>
|
||
<li><strong>Logo Upload:</strong> Add your company logo for branding</li>
|
||
<li><strong>Payment Methods:</strong> Add bank details, payment links, or QR codes</li>
|
||
<li><strong>Notes & Messages:</strong> Include payment terms, thank you messages, and authorized signatures</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">📤 Export Options:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>PDF:</strong> Generate professional PDF invoices for clients</li>
|
||
<li><strong>JSON:</strong> Save as reusable invoice templates</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="font-medium mb-1">💾 Data Privacy:</p>
|
||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||
<li><strong>Local Processing:</strong> All data stays in your browser</li>
|
||
<li><strong>No Upload:</strong> We don't store or transmit your invoice data</li>
|
||
<li><strong>Secure:</strong> Your business information remains private</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Error Display */}
|
||
{error && (
|
||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||
<div className="flex items-center gap-2">
|
||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||
<p className="text-red-700 dark:text-red-300">{error}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Settings Modal */}
|
||
{showSettingsModal && (
|
||
<InvoiceSettingsModal
|
||
invoiceData={invoiceData}
|
||
currencies={currencies}
|
||
onUpdateSettings={updateSettings}
|
||
onClose={() => setShowSettingsModal(false)}
|
||
logoInputRef={logoInputRef}
|
||
handleLogoUpload={handleLogoUpload}
|
||
removeLogo={removeLogo}
|
||
/>
|
||
)}
|
||
|
||
{/* Confirmation Modal */}
|
||
{showInputChangeModal && (
|
||
<InputChangeConfirmationModal
|
||
invoiceData={invoiceData}
|
||
currentMethod={activeTab}
|
||
newMethod={pendingTabChange}
|
||
onConfirm={confirmInputChange}
|
||
onCancel={cancelInputChange}
|
||
/>
|
||
)}
|
||
|
||
{/* Signature Drawing Modal */}
|
||
<SignaturePadModal
|
||
isOpen={showSignaturePad}
|
||
onClose={() => setShowSignaturePad(false)}
|
||
onSave={(dataURL) => {
|
||
updateInvoiceData('digitalSignature', dataURL);
|
||
setShowSignaturePad(false);
|
||
}}
|
||
/>
|
||
|
||
</div>
|
||
</ToolLayout>
|
||
);
|
||
};
|
||
|
||
// Searchable Currency Dropdown Component
|
||
const SearchableCurrencyDropdown = ({ currencies, selectedCurrency, onSelect }) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
|
||
const filteredCurrencies = currencies.filter(currency =>
|
||
currency.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
currency.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
(currency.symbol && currency.symbol.includes(searchTerm))
|
||
);
|
||
|
||
return (
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white text-left flex items-center justify-between"
|
||
>
|
||
<span>
|
||
{selectedCurrency?.symbol || selectedCurrency?.code || 'USD'} - {selectedCurrency?.name || 'US Dollar'} ({selectedCurrency?.code || 'USD'})
|
||
</span>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
|
||
{isOpen && (
|
||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
||
<div className="p-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Search currencies..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white text-sm"
|
||
/>
|
||
</div>
|
||
<div className="max-h-48 overflow-y-auto">
|
||
{filteredCurrencies.map(currency => (
|
||
<button
|
||
key={currency.code}
|
||
type="button"
|
||
onClick={() => {
|
||
onSelect(currency);
|
||
setIsOpen(false);
|
||
setSearchTerm('');
|
||
}}
|
||
className="w-full px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 text-sm dark:text-white"
|
||
>
|
||
{currency.symbol || currency.code} - {currency.name} ({currency.code})
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Invoice Settings Modal Component
|
||
const InvoiceSettingsModal = ({ invoiceData, currencies, onUpdateSettings, onClose, logoInputRef, handleLogoUpload, removeLogo }) => {
|
||
const [activeTab, setActiveTab] = useState('general');
|
||
|
||
const updatePaymentMethod = (field, value) => {
|
||
onUpdateSettings('paymentMethod', {
|
||
...invoiceData.paymentMethod,
|
||
[field]: value
|
||
});
|
||
};
|
||
|
||
const updatePaymentMethodNested = (section, field, value) => {
|
||
onUpdateSettings('paymentMethod', {
|
||
...invoiceData.paymentMethod,
|
||
[section]: {
|
||
...invoiceData.paymentMethod[section],
|
||
[field]: value
|
||
}
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||
<div className="px-6 py-4">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Invoice Settings</h3>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||
>
|
||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex border-b border-gray-200 dark:border-gray-600">
|
||
<button
|
||
onClick={() => setActiveTab('general')}
|
||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||
activeTab === 'general'
|
||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||
}`}
|
||
>
|
||
General
|
||
{activeTab === 'general' && (
|
||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400"></div>
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('layout')}
|
||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||
activeTab === 'layout'
|
||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||
}`}
|
||
>
|
||
Layout
|
||
{activeTab === 'layout' && (
|
||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400"></div>
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('payment')}
|
||
className={`px-6 py-3 text-sm font-medium relative transition-all duration-200 ${
|
||
activeTab === 'payment'
|
||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||
}`}
|
||
>
|
||
Payment
|
||
{activeTab === 'payment' && (
|
||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 dark:bg-blue-400"></div>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overflow-y-auto max-h-[60vh]">
|
||
{/* General Tab */}
|
||
{activeTab === 'general' && (
|
||
<div className="p-6 space-y-6">
|
||
{/* Logo Upload */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Attach Your Logo
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
{invoiceData.company.logo ? (
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={invoiceData.company.logo}
|
||
alt="Company Logo"
|
||
className="h-12 w-12 object-contain border border-gray-300 rounded-lg"
|
||
/>
|
||
<button
|
||
onClick={removeLogo}
|
||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 border border-red-300 hover:border-red-400 rounded-md transition-colors"
|
||
>
|
||
Remove Logo
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => logoInputRef.current?.click()}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
Attach Logo
|
||
</button>
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={logoInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleLogoUpload}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
|
||
{/* Color Scheme */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Color Scheme
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="color"
|
||
value={invoiceData.settings?.colorScheme || '#3B82F6'}
|
||
onChange={(e) => onUpdateSettings('colorScheme', e.target.value)}
|
||
className="w-12 h-12 rounded-lg border border-gray-300 cursor-pointer bg-transparent"
|
||
/>
|
||
<div>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
This color will be used throughout the invoice and PDF
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Current: {invoiceData.settings?.colorScheme || '#3B82F6'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Currency */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Currency
|
||
</label>
|
||
<SearchableCurrencyDropdown
|
||
currencies={currencies}
|
||
selectedCurrency={invoiceData.settings?.currency}
|
||
onSelect={(currency) => onUpdateSettings('currency', currency)}
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Selected: {invoiceData.settings?.currency?.symbol || invoiceData.settings?.currency?.code || '$'} ({invoiceData.settings?.currency?.code || 'USD'})
|
||
</p>
|
||
</div>
|
||
|
||
{/* Thousand Separator */}
|
||
<div>
|
||
<label className="flex items-center gap-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.thousandSeparator !== false}
|
||
onChange={(e) => onUpdateSettings('thousandSeparator', e.target.checked)}
|
||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||
/>
|
||
<div>
|
||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||
Use Thousand Separator
|
||
</span>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||
Format numbers like 1,000.00 instead of 1000.00
|
||
</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Decimal Digits */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Decimal Digits
|
||
</label>
|
||
<select
|
||
value={invoiceData.settings?.decimalDigits || 2}
|
||
onChange={(e) => onUpdateSettings('decimalDigits', parseInt(e.target.value))}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value={0}>0 (1000)</option>
|
||
<option value={1}>1 (1000.0)</option>
|
||
<option value={2}>2 (1000.00)</option>
|
||
<option value={3}>3 (1000.000)</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Number of decimal places to display
|
||
</p>
|
||
</div>
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* Layout Tab */}
|
||
{activeTab === 'layout' && (
|
||
<div className="p-6 space-y-6">
|
||
{/* Section Spacing */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Section Spacing
|
||
</label>
|
||
<select
|
||
value={invoiceData.settings?.sectionSpacing || 'normal'}
|
||
onChange={(e) => onUpdateSettings('sectionSpacing', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
>
|
||
<option value="compact">Compact (15px spacing)</option>
|
||
<option value="normal">Normal (25px spacing)</option>
|
||
<option value="spacious">Spacious (40px spacing)</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
Controls the spacing between major sections for better multi-page layout
|
||
</p>
|
||
</div>
|
||
|
||
{/* Page Break Controls */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Page Break Controls
|
||
</label>
|
||
<div className="space-y-3">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.pageBreaks?.beforePaymentSchedule || false}
|
||
onChange={(e) => onUpdateSettings('pageBreaks', {
|
||
...invoiceData.settings?.pageBreaks,
|
||
beforePaymentSchedule: e.target.checked
|
||
})}
|
||
className="mr-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Force page break before Payment Schedule</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.pageBreaks?.beforeItemsTable || false}
|
||
onChange={(e) => onUpdateSettings('pageBreaks', {
|
||
...invoiceData.settings?.pageBreaks,
|
||
beforeItemsTable: e.target.checked
|
||
})}
|
||
className="mr-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Force page break before Items Table</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={invoiceData.settings?.pageBreaks?.beforePaymentMethod || false}
|
||
onChange={(e) => onUpdateSettings('pageBreaks', {
|
||
...invoiceData.settings?.pageBreaks,
|
||
beforePaymentMethod: e.target.checked
|
||
})}
|
||
className="mr-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Force page break before Payment Method</span>
|
||
</label>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
Use page breaks to ensure important sections start on a new page in PDF output
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Payment Methods Tab */}
|
||
{activeTab === 'payment' && (
|
||
<div className="p-6 space-y-6">
|
||
{/* Payment Method Type */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Payment Method Display
|
||
</label>
|
||
<select
|
||
value={invoiceData.paymentMethod?.type || 'none'}
|
||
onChange={(e) => updatePaymentMethod('type', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="none">No Payment Method</option>
|
||
<option value="bank">Bank Details</option>
|
||
<option value="link">Payment Link</option>
|
||
<option value="qr">QR Code</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Choose how payment information appears on your invoice
|
||
</p>
|
||
</div>
|
||
|
||
|
||
{/* Bank Details */}
|
||
{invoiceData.paymentMethod?.type === 'bank' && (
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Bank Details</h4>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Bank Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.bankName || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'bankName', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., Chase Bank"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Account Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.accountName || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'accountName', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., John Doe"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Account Number
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.accountNumber || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'accountNumber', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., 1234567890"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Routing Number
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.routingNumber || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'routingNumber', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., 021000021"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
SWIFT Code
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.swiftCode || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'swiftCode', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., CHASUS33"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
IBAN
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.bankDetails?.iban || ''}
|
||
onChange={(e) => updatePaymentMethodNested('bankDetails', 'iban', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="e.g., GB29 NWBK 6016 1331 9268 19"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Payment Link */}
|
||
{invoiceData.paymentMethod?.type === 'link' && (
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Payment Link</h4>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Payment URL
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={invoiceData.paymentMethod?.paymentLink?.url || ''}
|
||
onChange={(e) => updatePaymentMethodNested('paymentLink', 'url', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="https://pay.stripe.com/..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Button Label
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.paymentLink?.label || 'Pay Online'}
|
||
onChange={(e) => updatePaymentMethodNested('paymentLink', 'label', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="Pay Online"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* QR Code */}
|
||
{invoiceData.paymentMethod?.type === 'qr' && (
|
||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">QR Code Payment</h4>
|
||
|
||
{/* QR Code Type Selection */}
|
||
<div className="mb-3">
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||
QR Code Type
|
||
</label>
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="qrType"
|
||
value="auto"
|
||
checked={invoiceData.paymentMethod?.qrCode?.customImage === undefined}
|
||
onChange={() => updatePaymentMethodNested('qrCode', 'customImage', undefined)}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Auto-generate from URL</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="qrType"
|
||
value="custom"
|
||
checked={invoiceData.paymentMethod?.qrCode?.customImage !== undefined}
|
||
onChange={() => updatePaymentMethodNested('qrCode', 'customImage', 'placeholder')}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">Upload custom QR code</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Auto-generate QR Code */}
|
||
{invoiceData.paymentMethod?.qrCode?.customImage === undefined && (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Payment URL
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={invoiceData.paymentMethod?.qrCode?.url || ''}
|
||
onChange={(e) => updatePaymentMethodNested('qrCode', 'url', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="https://pay.stripe.com/..."
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||
QR code will be automatically generated from this URL
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custom QR Code Upload */}
|
||
{invoiceData.paymentMethod?.qrCode?.customImage !== undefined && (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Upload QR Code Image
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
{invoiceData.paymentMethod?.qrCode?.customImage && invoiceData.paymentMethod.qrCode.customImage !== 'placeholder' ? (
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={invoiceData.paymentMethod.qrCode.customImage}
|
||
alt="Custom QR Code"
|
||
className="h-16 w-16 object-contain border border-gray-300 rounded-lg"
|
||
/>
|
||
<button
|
||
onClick={() => updatePaymentMethodNested('qrCode', 'customImage', 'placeholder')}
|
||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 border border-red-300 hover:border-red-400 rounded-md transition-colors"
|
||
>
|
||
Remove QR Code
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => document.getElementById('qr-upload').click()}
|
||
className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
Upload QR Code
|
||
</button>
|
||
)}
|
||
</div>
|
||
<input
|
||
id="qr-upload"
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
updatePaymentMethodNested('qrCode', 'customImage', e.target.result);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
}}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Common QR Code Label */}
|
||
<div className="mt-3">
|
||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
QR Code Label
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={invoiceData.paymentMethod?.qrCode?.label || 'Scan to Pay'}
|
||
onChange={(e) => updatePaymentMethodNested('qrCode', 'label', e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||
placeholder="Scan to Pay"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Payment Status */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Payment Status Stamp
|
||
</label>
|
||
<select
|
||
value={invoiceData.settings?.paymentStatus || ''}
|
||
onChange={(e) => onUpdateSettings('paymentStatus', e.target.value || null)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="">No Status Stamp</option>
|
||
<option value="PAID">PAID</option>
|
||
<option value="PARTIALLY PAID">PARTIALLY PAID</option>
|
||
<option value="UNPAID">UNPAID</option>
|
||
<option value="OVERDUE">OVERDUE</option>
|
||
<option value="PENDING">PENDING</option>
|
||
</select>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Add a status stamp to your invoice PDF
|
||
</p>
|
||
</div>
|
||
|
||
{/* Payment Date - only show when PAID is selected */}
|
||
{invoiceData.settings?.paymentStatus === 'PAID' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
Payment Date
|
||
</label>
|
||
<input
|
||
type="date"
|
||
value={invoiceData.settings?.paymentDate || ''}
|
||
onChange={(e) => onUpdateSettings('paymentDate', e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
/>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||
Date when payment was received
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
||
>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Input Change Confirmation Modal Component
|
||
const InputChangeConfirmationModal = ({ invoiceData, currentMethod, newMethod, onConfirm, onCancel }) => {
|
||
const getMethodName = (method) => {
|
||
switch (method) {
|
||
case 'create': return 'Create New';
|
||
case 'create_empty': return 'Start Empty';
|
||
case 'create_sample': return 'Load Sample';
|
||
case 'url': return 'URL Import';
|
||
case 'paste': return 'Paste Data';
|
||
case 'open': return 'File Upload';
|
||
default: return method;
|
||
}
|
||
};
|
||
|
||
const hasItems = invoiceData.items && invoiceData.items.length > 0;
|
||
const hasCompanyInfo = invoiceData.company && invoiceData.company.name;
|
||
const hasClientInfo = invoiceData.client && invoiceData.client.name;
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-amber-50 dark:bg-amber-900/20">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex-shrink-0">
|
||
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-amber-800 dark:text-amber-200">
|
||
Confirm Action
|
||
</h3>
|
||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||
{newMethod === 'create_empty' || newMethod === 'create_sample'
|
||
? `Using ${getMethodName(newMethod)} will clear your current invoice data.`
|
||
: `Switching from ${getMethodName(currentMethod)} to ${getMethodName(newMethod)} will clear your current invoice data.`
|
||
}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-6 py-4">
|
||
<div className="space-y-3">
|
||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||
You currently have:
|
||
</p>
|
||
<ul className="text-sm text-gray-700 dark:text-gray-300 space-y-1 ml-4">
|
||
{invoiceData.invoiceNumber && (
|
||
<li>• Invoice #{invoiceData.invoiceNumber}</li>
|
||
)}
|
||
{hasCompanyInfo && (
|
||
<li>• Company information ({invoiceData.company.name})</li>
|
||
)}
|
||
{hasClientInfo && (
|
||
<li>• Client information ({invoiceData.client.name})</li>
|
||
)}
|
||
{hasItems && (
|
||
<li>• {invoiceData.items.length} line item{invoiceData.items.length !== 1 ? 's' : ''}</li>
|
||
)}
|
||
</ul>
|
||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||
<strong>Tip:</strong> Consider downloading your current invoice as JSON before proceeding to save your work.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 flex justify-end gap-3">
|
||
<button
|
||
onClick={onCancel}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded-md hover:bg-gray-50 dark:hover:bg-gray-500 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={onConfirm}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-md transition-colors flex items-center gap-2"
|
||
>
|
||
<AlertTriangle className="h-4 w-4" />
|
||
Switch & Clear Data
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Signature Drawing Modal Component
|
||
const SignaturePadModal = ({ isOpen, onClose, onSave }) => {
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||
Draw Your Signature
|
||
</h3>
|
||
|
||
<div className="border-2 border-gray-300 dark:border-gray-600 rounded-lg mb-4">
|
||
<canvas
|
||
ref={(canvas) => {
|
||
if (canvas) {
|
||
const ctx = canvas.getContext('2d');
|
||
canvas.width = 400;
|
||
canvas.height = 150;
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
let isDrawing = false;
|
||
let lastX = 0;
|
||
let lastY = 0;
|
||
|
||
const startDrawing = (e) => {
|
||
isDrawing = true;
|
||
const rect = canvas.getBoundingClientRect();
|
||
lastX = e.clientX - rect.left;
|
||
lastY = e.clientY - rect.top;
|
||
};
|
||
|
||
const draw = (e) => {
|
||
if (!isDrawing) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const currentX = e.clientX - rect.left;
|
||
const currentY = e.clientY - rect.top;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(lastX, lastY);
|
||
ctx.lineTo(currentX, currentY);
|
||
ctx.strokeStyle = '#000000';
|
||
ctx.lineWidth = 2;
|
||
ctx.lineCap = 'round';
|
||
ctx.stroke();
|
||
|
||
lastX = currentX;
|
||
lastY = currentY;
|
||
};
|
||
|
||
const stopDrawing = () => {
|
||
isDrawing = false;
|
||
};
|
||
|
||
canvas.addEventListener('mousedown', startDrawing);
|
||
canvas.addEventListener('mousemove', draw);
|
||
canvas.addEventListener('mouseup', stopDrawing);
|
||
canvas.addEventListener('mouseout', stopDrawing);
|
||
}
|
||
}}
|
||
className="w-full h-32 cursor-crosshair"
|
||
style={{ touchAction: 'none' }}
|
||
/>
|
||
</div>
|
||
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||
Draw your signature in the box above using your mouse or touch device.
|
||
</p>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => {
|
||
const canvas = document.querySelector('canvas');
|
||
if (canvas) {
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
}
|
||
}}
|
||
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
Clear
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-white"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const canvas = document.querySelector('canvas');
|
||
if (canvas) {
|
||
const dataURL = canvas.toDataURL('image/png');
|
||
onSave(dataURL);
|
||
}
|
||
}}
|
||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||
>
|
||
Save Signature
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default InvoiceEditor;
|