import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import toast from 'react-hot-toast'; import CustomAlert from '../universal/CustomAlert'; // Animation variants const itemVariants = { hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } } }; // Invoice item interface export interface InvoiceItem { id: string; description: string; quantity: number; unitPrice: number; amount: number; } // Invoice data interface export interface InvoiceData { items: InvoiceItem[]; subtotal: number; taxRate: number; taxAmount: number; tipPercentage: number; tipAmount: number; total: number; vendor: string; } interface InvoiceBuilderProps { invoiceData: InvoiceData; onChange: (data: InvoiceData) => void; } const InvoiceBuilder: React.FC = ({ invoiceData, onChange }) => { // State for new item form with optional fields for input handling const [newItem, setNewItem] = useState<{ description: string; quantity: number | ''; unitPrice: number | string; }>({ description: '', quantity: 1, unitPrice: '' }); // State for form errors const [errors, setErrors] = useState<{ description?: string; quantity?: string; unitPrice?: string; vendor?: string; }>({}); // State for raw input values (to preserve exact user input) const [rawInputs, setRawInputs] = useState<{ taxAmount: string; tipAmount: string; }>({ taxAmount: '', tipAmount: '' }); // State for input validation messages const [validationMessages, setValidationMessages] = useState<{ vendor?: string; items?: string; tax?: string; tip?: string; }>({}); // Generate a unique ID for new items const generateId = () => { return `item-${Date.now()}-${Math.floor(Math.random() * 1000)}`; }; // Helper function to round to 2 decimal places for calculations only const roundToTwoDecimals = (num: number): number => { return Math.round((num + Number.EPSILON) * 100) / 100; }; // Update raw input values when invoiceData changes from outside useEffect(() => { if (invoiceData.taxAmount === 0 && rawInputs.taxAmount === '') { // Don't update if it's already empty and the value is 0 } else if (invoiceData.taxAmount.toString() !== rawInputs.taxAmount) { setRawInputs(prev => ({ ...prev, taxAmount: invoiceData.taxAmount === 0 ? '' : invoiceData.taxAmount.toString() })); } if (invoiceData.tipAmount === 0 && rawInputs.tipAmount === '') { // Don't update if it's already empty and the value is 0 } else if (invoiceData.tipAmount.toString() !== rawInputs.tipAmount) { setRawInputs(prev => ({ ...prev, tipAmount: invoiceData.tipAmount === 0 ? '' : invoiceData.tipAmount.toString() })); } }, [invoiceData.taxAmount, invoiceData.tipAmount]); // Validate the entire invoice useEffect(() => { const messages: { vendor?: string; items?: string; tax?: string; tip?: string; } = {}; // Validate vendor if (!invoiceData.vendor.trim()) { messages.vendor = 'Please enter a vendor/restaurant name'; } // Validate items if (invoiceData.items.length === 0) { messages.items = 'Please add at least one item to the invoice'; } // Validate tax (optional but must be valid if provided) if (rawInputs.taxAmount && isNaN(parseFloat(rawInputs.taxAmount))) { messages.tax = 'Tax amount must be a valid number'; } // Validate tip (optional but must be valid if provided) if (rawInputs.tipAmount && isNaN(parseFloat(rawInputs.tipAmount))) { messages.tip = 'Tip amount must be a valid number'; } setValidationMessages(messages); }, [invoiceData.vendor, invoiceData.items, rawInputs.taxAmount, rawInputs.tipAmount]); // Calculate subtotal, tax, tip, and total whenever items, tax rate, or tip percentage changes useEffect(() => { const subtotal = roundToTwoDecimals( invoiceData.items.reduce((sum, item) => sum + item.amount, 0) ); const taxAmount = roundToTwoDecimals((invoiceData.taxRate / 100) * subtotal); const tipAmount = roundToTwoDecimals((invoiceData.tipPercentage / 100) * subtotal); const total = roundToTwoDecimals(subtotal + taxAmount + tipAmount); // Only update if values have changed to prevent infinite loop if ( subtotal !== invoiceData.subtotal || taxAmount !== invoiceData.taxAmount || tipAmount !== invoiceData.tipAmount || total !== invoiceData.total ) { onChange({ ...invoiceData, subtotal, taxAmount, tipAmount, total }); } }, [invoiceData.items, invoiceData.taxRate, invoiceData.tipPercentage]); // Validate new item before adding const validateNewItem = () => { const newErrors: { description?: string; quantity?: string; unitPrice?: string; vendor?: string; } = {}; if (!newItem.description.trim()) { newErrors.description = 'Description is required'; } if (newItem.quantity === '' || typeof newItem.quantity === 'number' && newItem.quantity <= 0) { newErrors.quantity = 'Quantity must be greater than 0'; } if (newItem.unitPrice === '' || typeof newItem.unitPrice === 'number' && newItem.unitPrice < 0) { newErrors.unitPrice = 'Unit price must be 0 or greater'; } // Check for duplicate description const isDuplicate = invoiceData.items.some( item => item.description.toLowerCase() === newItem.description.toLowerCase() ); if (isDuplicate) { newErrors.description = 'An item with this description already exists'; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // Add a new item to the invoice const handleAddItem = () => { if (!validateNewItem()) { return; } // Calculate amount with proper rounding for display and calculations const quantity = typeof newItem.quantity === 'number' ? newItem.quantity : 0; const unitPrice = typeof newItem.unitPrice === 'number' ? newItem.unitPrice : typeof newItem.unitPrice === 'string' && newItem.unitPrice !== '' ? parseFloat(newItem.unitPrice) : 0; const amount = roundToTwoDecimals(quantity * unitPrice); // Create new item const item: InvoiceItem = { id: generateId(), description: newItem.description, quantity: quantity, unitPrice: unitPrice, // Store the exact value amount }; // Add item to invoice onChange({ ...invoiceData, items: [...invoiceData.items, item] }); // Show success toast toast.success(`Added ${item.description} to invoice`); // Reset new item form setNewItem({ description: '', quantity: 1, unitPrice: '' }); // Clear errors setErrors({}); }; // Remove an item const handleRemoveItem = (id: string) => { const itemToRemove = invoiceData.items.find(item => item.id === id); onChange({ ...invoiceData, items: invoiceData.items.filter(item => item.id !== id) }); if (itemToRemove) { toast.success(`Removed ${itemToRemove.description} from invoice`); } }; // Format currency const formatCurrency = (amount: number) => { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); }; // Update tax amount directly - preserve exact input const handleTaxAmountChange = (e: React.ChangeEvent) => { const value = e.target.value; // Store the raw input value setRawInputs(prev => ({ ...prev, taxAmount: value })); if (value === '' || /^\d*\.?\d*$/.test(value)) { const taxAmount = value === '' ? 0 : parseFloat(value); const taxRate = invoiceData.subtotal > 0 && !isNaN(taxAmount) ? roundToTwoDecimals((taxAmount / invoiceData.subtotal) * 100) : 0; onChange({ ...invoiceData, taxAmount: isNaN(taxAmount) ? 0 : taxAmount, taxRate }); } }; // Update tip amount directly - preserve exact input const handleTipAmountChange = (e: React.ChangeEvent) => { const value = e.target.value; // Store the raw input value setRawInputs(prev => ({ ...prev, tipAmount: value })); if (value === '' || /^\d*\.?\d*$/.test(value)) { const tipAmount = value === '' ? 0 : parseFloat(value); const tipPercentage = invoiceData.subtotal > 0 && !isNaN(tipAmount) ? roundToTwoDecimals((tipAmount / invoiceData.subtotal) * 100) : 0; onChange({ ...invoiceData, tipAmount: isNaN(tipAmount) ? 0 : tipAmount, tipPercentage }); } }; // Custom CSS to hide spinner buttons on number inputs const numberInputStyle = { /* For Chrome, Safari, Edge, Opera */ WebkitAppearance: 'none', margin: 0, /* For Firefox */ MozAppearance: 'textfield' } as React.CSSProperties; return (

Invoice Builder

{/* AS Funding Limit Notice */} {/* Vendor information */}
{ onChange({ ...invoiceData, vendor: e.target.value }); // Clear vendor error if it exists if (errors.vendor && e.target.value.trim()) { setErrors({ ...errors, vendor: undefined }); } }} placeholder="e.g. L&L Hawaiian Barbeque" /> {(errors.vendor || validationMessages.vendor) && ( )}
{/* Item list */}
{invoiceData.items.map(item => ( ))} {invoiceData.items.length === 0 && ( )}
Description Qty Unit Price Amount
{item.description} {item.quantity} {formatCurrency(item.unitPrice)} {formatCurrency(item.amount)}
No items added yet
{validationMessages.items && (
{validationMessages.items}
)} {/* Add new item form */}
setNewItem({ ...newItem, description: e.target.value })} placeholder="e.g. Chicken Cutlet with Gravy" /> {errors.description && ( )}
{ const value = e.target.value; if (value === '' || /^\d*$/.test(value)) { setNewItem({ ...newItem, quantity: value === '' ? '' : parseInt(value) || 0 }); } }} placeholder="Enter quantity" /> {errors.quantity &&
{errors.quantity}
}
{ const value = e.target.value; if (value === '' || /^\d*\.?\d*$/.test(value)) { setNewItem({ ...newItem, unitPrice: value }); } }} placeholder="Enter price" /> {errors.unitPrice && ( )}
{/* Tax and tip */}
{validationMessages.tax && ( )}
{validationMessages.tip && ( )}
{/* Totals */}
Subtotal: {formatCurrency(invoiceData.subtotal)}
Tax: {formatCurrency(invoiceData.taxAmount)}
Tip: {formatCurrency(invoiceData.tipAmount)}
Total: {formatCurrency(invoiceData.total)}
{/* Validation notice */} {invoiceData.items.length === 0 && ( )} {/* Important Note */}
); }; export default InvoiceBuilder;