'use client' import { useState, useEffect } from 'react' import FileUpload from './FileUpload' interface Participant { id: number name: string is_creator: boolean } interface ExpenseModalProps { isOpen: boolean onClose: () => void tripId: number participants: Participant[] tripCurrency: string onExpenseCreated: (expense: any) => void } const commonCategories = [ 'Food & Dining', 'Transportation', 'Accommodation', 'Entertainment', 'Shopping', 'Healthcare', 'Education', 'Business', 'Other' ] // Comprehensive currency list - moved outside component to prevent hydration issues 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 ExpenseModal({ isOpen, onClose, tripId, participants, tripCurrency, onExpenseCreated }: ExpenseModalProps) { const [description, setDescription] = useState('') const [amount, setAmount] = useState('') const [currency, setCurrency] = useState(tripCurrency) const [category, setCategory] = useState('') const [paidById, setPaidById] = useState('') const [splits, setSplits] = useState<{ participant_id: number; percentage: number }[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [splitRotationOffset, setSplitRotationOffset] = useState(0) const [exchangeRate, setExchangeRate] = useState('') const [useCustomRate, setUseCustomRate] = useState(false) const [useEqualSplit, setUseEqualSplit] = useState(true) // Default to equal split mode const [selectedParticipants, setSelectedParticipants] = useState([]) // For equal split checkboxes const [receiptFile, setReceiptFile] = useState(null) const [receiptUrl, setReceiptUrl] = useState(null) const [uploadingReceipt, setUploadingReceipt] = useState(false) // 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 } }) } // Get display percentage - always show the base equal split for visual harmony const getDisplayPercentage = (participantCount: number) => { return Math.floor(100 / participantCount) } // 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) } // Update currency when tripCurrency changes useEffect(() => { setCurrency(tripCurrency) }, [tripCurrency]) // Initialize splits when participants change useEffect(() => { if (participants.length > 0) { // Initialize selected participants for equal split (all selected by default) if (selectedParticipants.length === 0) { const allIds = participants.map(p => p.id) setSelectedParticipants(allIds) if (useEqualSplit) { updateEqualSplits(allIds) } } // Initialize splits if empty if (splits.length === 0 && !useEqualSplit) { const initialSplits = calculateFairSplits(participants.length) setSplits(initialSplits) } } }, [participants]) // 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) } // Fetch current exchange rate when currency changes useEffect(() => { if (currency !== tripCurrency && !useCustomRate) { fetchCurrentExchangeRate() } else if (currency === tripCurrency) { setExchangeRate('1.00') setUseCustomRate(false) } }, [currency]) const fetchCurrentExchangeRate = async () => { if (currency === tripCurrency) { setExchangeRate('1.00') 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) } } catch (error) { console.error('Failed to fetch exchange rate:', error) // Keep current exchange rate if fetch fails } } const uploadReceipt = async (file: File, expenseId?: number) => { if (!file) return null setUploadingReceipt(true) try { const formData = new FormData() formData.append('file', file) formData.append('trip_id', tripId.toString()) if (expenseId) { formData.append('expense_id', expenseId.toString()) } const response = await fetch('/api/uploads/receipt', { method: 'POST', body: formData, }) if (response.ok) { const data = await response.json() setReceiptUrl(data.file_url) return data.file_url } else { const errorData = await response.json() setError(errorData.detail || 'Failed to upload receipt') return null } } catch (error) { console.error('Error uploading receipt:', error) setError('Failed to upload receipt') return null } finally { setUploadingReceipt(false) } } const resetForm = () => { setDescription('') setAmount('') setCategory('') setPaidById('') setExchangeRate('') setUseCustomRate(false) setUseEqualSplit(true) setSelectedParticipants([]) setSplits([]) setReceiptFile(null) setReceiptUrl(null) setError('') } const handleClose = () => { resetForm() onClose() } 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: select all participants and calculate equal splits const allIds = participants.map(p => p.id) setSelectedParticipants(allIds) updateEqualSplits(allIds) } else { // Switch to custom: initialize with equal splits but allow editing const initialSplits = calculateFairSplits(participants.length) setSplits(initialSplits) } } const evenSplitSplits = () => { // Increment rotation to give different people the extra percentage over time const newOffset = splitRotationOffset + 1 setSplitRotationOffset(newOffset) const newSplits = calculateFairSplits(participants.length, newOffset) setSplits(newSplits) } 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 } // Validation will always pass since we're managing splits internally // The actual splits always total 100% in the backend return true } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!validateForm()) 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 receipt first if there's a file let finalReceiptUrl: string | null = null if (receiptFile) { finalReceiptUrl = await uploadReceipt(receiptFile) if (!finalReceiptUrl) { setLoading(false) return } } 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, receipt_url: finalReceiptUrl } // Online - try to create expense (use relative URL for mobile compatibility) const { getAuthHeaders } = await import('@/lib/trip-auth') const response = await fetch(`/api/expenses/${tripId}/expenses`, { method: 'POST', 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 create expense') } const expense = await response.json() onExpenseCreated(expense) handleClose() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create expense. Please try again.') } finally { setLoading(false) } } if (!isOpen) return null return (

Add Expense

{error && (
{error}
)} {/* Description */}
setDescription(e.target.value)} placeholder="Lunch at restaurant, Taxi to airport..." className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent" required />
{/* Amount */}
setAmount(e.target.value)} placeholder="0.00" className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent min-w-0" required />
{/* Exchange Rate */} {currency !== tripCurrency && (
{ setExchangeRate(e.target.value) setUseCustomRate(true) }} 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 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent min-w-0" required />

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

)} {/* Category */}
{/* Receipt Upload */}
setReceiptFile(file)} onFileRemove={() => { setReceiptFile(null) setReceiptUrl(null) }} selectedFile={receiptFile} tripId={tripId} className={uploadingReceipt ? 'opacity-50' : ''} disabled={loading || uploadingReceipt} accept="image/*" /> {uploadingReceipt && (

Uploading receipt...

)}
{/* Paid By */}
{/* Split Section */}
{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-400 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 dark:focus:ring-blue-400 bg-white 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 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400" 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 */}
) }