'use client' import { useState, useEffect } from 'react' import FileUpload from './FileUpload' import ReceiptViewer from './ReceiptViewer' interface Participant { id: number name: string is_creator: boolean } interface Expense { id: number description: string amount: number currency_code: string exchange_rate: number category?: string paid_by_id: number splits: Array<{ participant_id: number percentage: number amount: number }> date_incurred: string receipt_url?: string } interface EditExpenseModalProps { isOpen: boolean onClose: () => void expense: Expense | null participants: Participant[] tripCurrency: string tripId: number onExpenseUpdated: () => void } const commonCategories = [ 'Food & Dining', 'Transportation', 'Accommodation', 'Entertainment', 'Shopping', 'Healthcare', 'Education', 'Business', 'Other' ] // Comprehensive currency list const currencies = [ { code: 'USD', name: 'US Dollar', symbol: '$' }, { code: 'EUR', name: 'Euro', symbol: '€' }, { code: 'GBP', name: 'British Pound', symbol: '£' }, { code: 'JPY', name: 'Japanese Yen', symbol: '¥' }, { code: 'CNY', name: 'Chinese Yuan', symbol: '¥' }, { code: 'INR', name: 'Indian Rupee', symbol: '₹' }, { code: 'AED', name: 'UAE Dirham', symbol: 'د.إ' }, { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' }, { code: 'AUD', name: 'Australian Dollar', symbol: 'A$' }, { code: 'CHF', name: 'Swiss Franc', symbol: 'Fr' }, { code: 'SEK', name: 'Swedish Krona', symbol: 'kr' }, { code: 'NOK', name: 'Norwegian Krone', symbol: 'kr' }, { code: 'DKK', name: 'Danish Krone', symbol: 'kr' }, { code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' }, { code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$' }, { code: 'NZD', name: 'New Zealand Dollar', symbol: 'NZ$' }, { code: 'MXN', name: 'Mexican Peso', symbol: '$' }, { code: 'BRL', name: 'Brazilian Real', symbol: 'R$' }, { code: 'ZAR', name: 'South African Rand', symbol: 'R' }, { code: 'KRW', name: 'South Korean Won', symbol: '₩' }, { code: 'THB', name: 'Thai Baht', symbol: '฿' }, { code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' }, { code: 'PHP', name: 'Philippine Peso', symbol: '₱' }, { code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' }, { code: 'VND', name: 'Vietnamese Dong', symbol: '₫' } ] export default function EditExpenseModal({ isOpen, onClose, expense, participants, tripCurrency, tripId, onExpenseUpdated }: EditExpenseModalProps) { const [description, setDescription] = useState('') const [amount, setAmount] = useState('') const [currency, setCurrency] = useState(tripCurrency) const [category, setCategory] = useState('') const [paidById, setPaidById] = useState('') const [dateIncurred, setDateIncurred] = useState('') const [splits, setSplits] = useState<{ participant_id: number; percentage: number }[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [exchangeRate, setExchangeRate] = useState('') const [useCustomRate, setUseCustomRate] = useState(false) const [useEqualSplit, setUseEqualSplit] = useState(true) const [selectedParticipants, setSelectedParticipants] = useState([]) const [receiptFile, setReceiptFile] = useState(null) const [receiptUrl, setReceiptUrl] = useState('') const [uploadingReceipt, setUploadingReceipt] = useState(false) const [showReceiptViewer, setShowReceiptViewer] = useState(false) const [rateWarning, setRateWarning] = useState(null) // Calculate fair splits by rotating who gets the extra percentage const calculateFairSplits = (participantCount: number, offset: number = 0) => { const equalSplit = Math.floor(100 / participantCount) let remainder = 100 - (equalSplit * participantCount) return Array.from({ length: participantCount }, (_, index) => { let percentage = equalSplit // Rotate who gets the extra percentage based on offset const rotatedIndex = (index + offset) % participantCount if (rotatedIndex < remainder) { percentage += 1 } return { participant_id: participants[index]?.id || 0, percentage: percentage } }) } // Update equal splits when selected participants change const updateEqualSplits = (selectedIds: number[]) => { if (selectedIds.length === 0) { setSplits([]) return } // Round down to equal percentage for all (e.g., 33% for all instead of 34%, 33%, 33%) const equalPercentage = Math.floor(100 / selectedIds.length) const newSplits = selectedIds.map((participantId) => ({ participant_id: participantId, percentage: equalPercentage })) setSplits(newSplits) } // Initialize form when expense changes useEffect(() => { if (expense) { setDescription(expense.description) setAmount(expense.amount.toString()) setCurrency(expense.currency_code) setCategory(expense.category || '') setPaidById(expense.paid_by_id.toString()) // Format exchange rate to 4 decimal places, removing trailing zeros const formattedRate = parseFloat(expense.exchange_rate.toFixed(4)).toString() setExchangeRate(formattedRate) // Don't force custom rate on edit - let user decide setUseCustomRate(expense.exchange_rate !== null && expense.exchange_rate > 0) // Format date for datetime-local input (YYYY-MM-DDTHH:mm) const date = new Date(expense.date_incurred) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') setDateIncurred(`${year}-${month}-${day}T${hours}:${minutes}`) // Set receipt URL from existing expense setReceiptUrl(expense.receipt_url || '') // Set splits from existing expense const existingSplits = expense.splits.map(split => ({ participant_id: split.participant_id, percentage: split.percentage })) setSplits(existingSplits) // Check if splits are equal to determine mode const percentages = existingSplits.map(s => s.percentage) const allEqual = percentages.length > 0 && percentages.every(p => p === percentages[0]) setUseEqualSplit(allEqual) // Set selected participants for equal split mode if (allEqual) { setSelectedParticipants(existingSplits.map(s => s.participant_id)) } else { setSelectedParticipants([]) } } }, [expense]) // Fetch current exchange rate when currency changes const fetchCurrentExchangeRate = async () => { if (currency === tripCurrency) { setExchangeRate('1.00') setRateWarning(null) return } try { const response = await fetch(`/api/currency/convert?amount=1&from_currency=${currency}&to_currency=${tripCurrency}`) if (response.ok) { const data = await response.json() // Format to 4 decimal places for precision, but remove trailing zeros const formattedRate = parseFloat(data.exchange_rate.toFixed(4)).toString() setExchangeRate(formattedRate) setUseCustomRate(false) setRateWarning(null) } } catch (error) { console.error('Failed to fetch exchange rate:', error) // Keep current exchange rate if fetch fails } } // Validate custom exchange rate against market rate const validateCustomRate = async (customRate: string) => { if (!customRate || currency === tripCurrency) { setRateWarning(null) return } try { const response = await fetch(`/api/currency/convert?amount=1&from_currency=${currency}&to_currency=${tripCurrency}`) if (response.ok) { const data = await response.json() const marketRate = data.exchange_rate const customRateNum = parseFloat(customRate) if (!isNaN(customRateNum) && customRateNum > 0) { const difference = Math.abs(customRateNum - marketRate) / marketRate if (difference > 0.1) { // More than 10% difference const percentageDiff = (difference * 100).toFixed(1) setRateWarning( `⚠️ Your custom rate (${customRateNum.toFixed(4)}) differs by ${percentageDiff}% from market rate (${marketRate.toFixed(4)}). Please verify it's correct.` ) } else { setRateWarning(null) } } } } catch (error) { console.error('Failed to fetch market rate for validation:', error) // Don't show warning if we can't validate } } // Upload receipt image const uploadReceipt = async (file: File): Promise => { setUploadingReceipt(true) setError('') try { const formData = new FormData() formData.append('file', file) formData.append('trip_id', tripId.toString()) if (expense) { formData.append('expense_id', expense.id.toString()) } const response = await fetch('/api/uploads/receipt', { method: 'POST', body: formData, }) if (!response.ok) { const errorData = await response.json() throw new Error(errorData.detail || 'Failed to upload receipt') } const data = await response.json() return data.file_url } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to upload receipt' setError(errorMessage) throw err } finally { setUploadingReceipt(false) } } // Handle file selection for receipt const handleReceiptFileSelect = (file: File) => { setReceiptFile(file) setReceiptUrl('') // Clear existing URL when new file is selected } // Handle file removal const handleReceiptFileRemove = () => { setReceiptFile(null) setReceiptUrl('') } // Handle deletion of existing receipt const handleDeleteReceipt = async () => { if (!receiptUrl) return try { const response = await fetch(`/api/uploads/receipt`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ file_url: receiptUrl }), }) if (response.ok) { setReceiptUrl('') setReceiptFile(null) console.log('Receipt deleted successfully') } else { const errorData = await response.json() console.error('Failed to delete receipt:', errorData.detail) } } catch (error) { console.error('Error deleting receipt:', error) } } // Handle checkbox toggle for equal split mode const handleParticipantToggle = (participantId: number) => { const newSelected = selectedParticipants.includes(participantId) ? selectedParticipants.filter(id => id !== participantId) : [...selectedParticipants, participantId] setSelectedParticipants(newSelected) updateEqualSplits(newSelected) } const handleSplitChange = (participantId: number, newPercentage: number) => { // Ensure the percentage is a whole number between 0 and 100 const clampedPercentage = Math.max(0, Math.min(100, Math.round(newPercentage))) // Check if this participant already has a split const existingSplit = splits.find(s => s.participant_id === participantId) let updatedSplits: { participant_id: number; percentage: number }[] if (clampedPercentage === 0) { // Remove split if percentage is 0 updatedSplits = splits.filter(s => s.participant_id !== participantId) } else if (existingSplit) { // Update existing split updatedSplits = splits.map(split => split.participant_id === participantId ? { ...split, percentage: clampedPercentage } : split ) } else { // Add new split updatedSplits = [...splits, { participant_id: participantId, percentage: clampedPercentage }] } setSplits(updatedSplits) } // Handle mode switch between equal split and custom percentages const handleModeSwitch = (equalSplitMode: boolean) => { setUseEqualSplit(equalSplitMode) if (equalSplitMode) { // Switch to equal split: use current splits or select all const currentIds = splits.length > 0 ? splits.map(s => s.participant_id) : participants.map(p => p.id) setSelectedParticipants(currentIds) updateEqualSplits(currentIds) } // When switching to custom, keep current splits } const validateForm = () => { if (!description.trim()) { setError('Please enter a description') return false } if (!amount || parseFloat(amount) <= 0) { setError('Please enter a valid amount') return false } if (!paidById) { setError('Please select who paid') return false } if (!exchangeRate || parseFloat(exchangeRate) <= 0) { setError('Please enter a valid exchange rate') return false } return true } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!validateForm() || !expense) return setLoading(true) setError('') try { // Use custom exchange rate if provided, otherwise fetch current rate let finalExchangeRate = 1.0 if (currency !== tripCurrency) { if (useCustomRate && exchangeRate) { finalExchangeRate = parseFloat(exchangeRate) } else { try { // Use relative URL for mobile compatibility const rateResponse = await fetch( `/api/currency/convert?amount=1&from_currency=${currency}&to_currency=${tripCurrency}` ) if (rateResponse.ok) { const rateData = await rateResponse.json() finalExchangeRate = rateData.exchange_rate } } catch (rateErr) { console.warn('Failed to fetch exchange rate, using 1.0:', rateErr) } } } // Filter out splits with 0 percentage (backend requires percentage > 0) const validSplits = splits .filter(split => split.percentage > 0) .map(split => ({ participant_id: split.participant_id, percentage: split.percentage })) // Validate that we have at least one valid split if (validSplits.length === 0) { setError('At least one participant must have a percentage greater than 0') setLoading(false) return } // Validate that splits sum to 100% (allow 98-100.01% to account for rounding down in equal splits) const totalPercentage = validSplits.reduce((sum, split) => sum + split.percentage, 0) // Accept 98-100.01% to handle equal splits that round down (e.g., 33% x 3 = 99%, or 25% x 4 = 100%) // Using 98 as minimum to account for any floating point precision issues if (totalPercentage < 98 || totalPercentage > 100.01) { setError(`Splits must sum to 100%. Current total: ${totalPercentage.toFixed(2)}%`) setLoading(false) return } // Upload new receipt if selected let finalReceiptUrl = receiptUrl if (receiptFile && !receiptUrl) { try { finalReceiptUrl = await uploadReceipt(receiptFile) } catch (receiptErr) { setLoading(false) return // Error is already set in uploadReceipt } } // Parse date_incurred from datetime-local format const dateIncurredDate = dateIncurred ? new Date(dateIncurred) : null const expenseData = { description: description.trim(), amount: parseFloat(amount), currency_code: currency, exchange_rate: finalExchangeRate, category: category.trim() || null, paid_by_id: parseInt(paidById), splits: validSplits, date_incurred: dateIncurredDate ? dateIncurredDate.toISOString() : null, receipt_url: finalReceiptUrl || null } // Update expense (use relative URL for mobile compatibility) const { getAuthHeaders } = await import('@/lib/trip-auth') const response = await fetch(`/api/expenses/expenses/${expense.id}`, { method: 'PUT', headers: { ...getAuthHeaders(tripId), 'Content-Type': 'application/json', }, body: JSON.stringify(expenseData), }) if (!response.ok) { const errorData = await response.json() throw new Error(errorData.detail || 'Failed to update expense') } await response.json() onExpenseUpdated() handleClose() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update expense') } finally { setLoading(false) } } const handleClose = () => { setDescription('') setAmount('') setCategory('') setPaidById('') setDateIncurred('') setExchangeRate('') setUseCustomRate(false) setSplits([]) setReceiptFile(null) setReceiptUrl('') setUploadingReceipt(false) setShowReceiptViewer(false) setRateWarning(null) setError('') onClose() } if (!isOpen) return null return (

Edit Expense

{error && (
{error}
)}
{/* Description */}
setDescription(e.target.value)} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="What was this expense for?" required />
{/* Date Incurred */}
setDateIncurred(e.target.value)} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" required />
{/* Amount and Currency */}
setAmount(e.target.value)} placeholder="0.00" className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0" required />
{/* Exchange Rate */} {currency !== tripCurrency && (
{ const newRate = e.target.value setExchangeRate(newRate) setUseCustomRate(true) // Validate the custom rate against market rate validateCustomRate(newRate) }} placeholder="0.00" step="0.0001" min="0" className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" required />

Enter custom rate or click "Get Current Rate" for live exchange rate

{rateWarning && (
{rateWarning}
)}
)} {/* Category */}
{/* Receipt */}
{/* Show current receipt if exists */} {receiptUrl && !receiptFile && ( <>

Current receipt

Click view to see full size

)} {/* File upload component */} {uploadingReceipt && (

Uploading receipt...

)}
{/* Paid By */}
{/* Split Between */}
{useEqualSplit ? ( // Equal Split Mode: Use checkboxes (no percentage display)
{participants.map((participant) => { const isSelected = selectedParticipants.includes(participant.id) return (
handleParticipantToggle(participant.id)} className="w-4 h-4 text-blue-600 dark:text-blue-500 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 dark:bg-gray-700" /> {participant.name} {participant.is_creator && '(Creator)'}
) })}
) : ( // Custom Percentage Mode: Use number inputs
{participants.map((participant) => { const split = splits.find(s => s.participant_id === participant.id) const percentage = split?.percentage || 0 return (
{participant.name} {participant.is_creator && '(Creator)'}
handleSplitChange(participant.id, parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" min="0" max="100" /> %
) })}
)}
Total: { const total = splits.reduce((sum, s) => sum + s.percentage, 0) return total >= 98 && total <= 100.01 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' })() }`}> {splits.reduce((sum, s) => sum + s.percentage, 0).toFixed(0)}%
{/* Action Buttons */}
{/* Receipt Viewer Modal */} {showReceiptViewer && receiptUrl && ( setShowReceiptViewer(false)} title="Receipt" showDownload={true} /> )}
) }