Spaces:
Sleeping
Sleeping
| import { useState, useMemo } from 'react'; | |
| import { useWallets, useTransactions, useRates, useCreateTransaction, useExchanges, useCreateExchange } from '../hooks/queries'; | |
| import { Wallet as WalletIcon, DollarSign, Coins, Bitcoin, MessageCircle, ShieldCheck, Zap, CreditCard, PlusCircle, RefreshCw, MinusCircle } from 'lucide-react'; | |
| import { Button } from '../components/ui/button'; | |
| import { Input } from '../components/ui/input'; | |
| import { AmountInput } from '../components/ui/AmountInput'; | |
| import { Select } from '../components/ui/select'; | |
| import { Label } from '../components/ui/label'; | |
| export default function WalletsView() { | |
| const getWalletStyling = (name: string) => { | |
| const n = name.toLowerCase(); | |
| if (n.includes('dollar')) return { icon: DollarSign, color: 'text-emerald-400', bg: 'bg-emerald-500/20', border: 'border-emerald-500/30' }; | |
| if (n.includes('dinnar') || n.includes('dinar')) return { icon: Coins, color: 'text-indigo-400', bg: 'bg-indigo-500/20', border: 'border-indigo-500/30' }; | |
| if (n.includes('crypto')) return { icon: Bitcoin, color: 'text-orange-400', bg: 'bg-orange-500/20', border: 'border-orange-500/30' }; | |
| if (n.includes('wechat')) return { icon: MessageCircle, color: 'text-green-400', bg: 'bg-green-500/20', border: 'border-green-500/30' }; | |
| if (n.includes('alipay')) return { icon: ShieldCheck, color: 'text-blue-400', bg: 'bg-blue-500/20', border: 'border-blue-500/30' }; | |
| if (n.includes('fib')) return { icon: Zap, color: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30' }; | |
| if (n.includes('fastpay')) return { icon: Zap, color: 'text-red-400', bg: 'bg-red-500/20', border: 'border-red-500/30' }; | |
| if (n.includes('super qi')) return { icon: CreditCard, color: 'text-blue-400', bg: 'bg-blue-500/20', border: 'border-blue-500/30' }; | |
| if (n.includes('kj wallets')) return { icon: WalletIcon, color: 'text-purple-400', bg: 'bg-purple-500/20', border: 'border-purple-500/30' }; | |
| return { icon: WalletIcon, color: 'text-indigo-400', bg: 'bg-indigo-500/20', border: 'border-indigo-500/30' }; | |
| }; | |
| const getAllowedDestinations = (sourceName: string) => { | |
| const name = sourceName.toLowerCase(); | |
| if (name.includes('usd') && !name.includes('usdt')) return ['USDT', 'Cash Dinar', 'Alipay', 'WeChat']; | |
| if (name.includes('kj wallets')) return ['Cash USD', 'FIB']; | |
| if (name.includes('dinar')) return ['FIB', 'FastPay', 'Super Qi', 'Cash USD']; | |
| if (name.includes('usdt')) return ['Cash USD']; | |
| if (name.includes('fib')) return ['FastPay', 'Super Qi', 'Cash Dinar']; | |
| if (name.includes('fastpay')) return ['FIB', 'Super Qi', 'Cash Dinar']; | |
| if (name.includes('qi')) return ['FastPay', 'FIB', 'Cash Dinar']; | |
| if (name.includes('wechat')) return ['Alipay', 'Cash USD']; | |
| if (name.includes('alipay')) return ['WeChat', 'Cash USD']; | |
| return []; | |
| }; | |
| const { data: rawWallets = [] } = useWallets(); | |
| const wallets = useMemo(() => { | |
| const order = ['cash usd', 'cash dinar', 'super qi', 'alipay', 'wechat', 'fib', 'fastpay', 'usdt', 'kj wallets']; | |
| return [...rawWallets].sort((a: any, b: any) => { | |
| const idxA = order.indexOf(a.name.toLowerCase()); | |
| const idxB = order.indexOf(b.name.toLowerCase()); | |
| return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); | |
| }); | |
| }, [rawWallets]); | |
| const { data: transactions = [] } = useTransactions(); | |
| const { data: exchanges = [] } = useExchanges(); | |
| const { data: rates } = useRates(); | |
| const { mutateAsync: createTransaction } = useCreateTransaction(); | |
| const { mutateAsync: createExchange } = useCreateExchange(); | |
| const [actionState, setActionState] = useState<{type: 'transfer' | 'income' | 'expense' | 'exchange', walletId?: number} | null>(null); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| // Form States | |
| const [amount, setAmount] = useState(''); | |
| const [toAmount, setToAmount] = useState(''); | |
| const [toWallet, setToWallet] = useState(''); | |
| const [note, setNote] = useState(''); | |
| const [category, setCategory] = useState('other'); | |
| const exchangeRates = rates || { USD: 1, IQD: 1539.5, RMB: 6.86 }; | |
| // Calculate Balances dynamically factoring in currency differences | |
| const getBalance = (w: any) => { | |
| const txBal = transactions.reduce((acc: number, tx: any) => { | |
| let effectiveAmount = tx.amount; | |
| if (tx.currency !== w.currency) { | |
| const txRate = exchangeRates[tx.currency] || 1; | |
| const walletRate = exchangeRates[w.currency] || 1; | |
| effectiveAmount = (tx.amount / txRate) * walletRate; | |
| } | |
| if (tx.type === 'income' && tx.wallet_id === w.id) return acc + effectiveAmount; | |
| if (tx.type === 'expense' && tx.wallet_id === w.id) return acc - effectiveAmount; | |
| if (tx.type === 'transfer') { | |
| if (tx.wallet_id === w.id) return acc - effectiveAmount; | |
| if (tx.to_wallet_id === w.id) return acc + effectiveAmount; | |
| } | |
| return acc; | |
| }, 0); | |
| const exBal = exchanges.reduce((acc: number, ex: any) => { | |
| let bal = 0; | |
| if (ex.from_wallet_id === w.id) bal -= ex.from_amount; | |
| if (ex.to_wallet_id === w.id) bal += ex.to_amount; | |
| return acc + bal; | |
| }, 0); | |
| return txBal + exBal; | |
| }; | |
| const activeWallet = actionState?.walletId ? wallets.find((w: any) => w.id === actionState.walletId) : null; | |
| const currentBalance = activeWallet ? getBalance(activeWallet) : 0; | |
| const enteredAmount = parseFloat(amount) || 0; | |
| const isInsufficient = (actionState?.type === 'expense' || actionState?.type === 'exchange') && enteredAmount > currentBalance; | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (isInsufficient) return; | |
| setIsSubmitting(true); | |
| try { | |
| if (actionState?.type === 'income' || actionState?.type === 'expense') { | |
| const targetWallet = wallets.find((w: any) => w.id === actionState.walletId); | |
| if (!targetWallet) return; | |
| let finalCategory = category; | |
| if (actionState.type === 'income' && category === 'other') { | |
| // If it was the default 'other' but from a previous expense, and we are in income now, | |
| // we might want to ensure it's handled correctly if not changed. | |
| // But actually, the state is shared. So we just use 'category' state. | |
| } | |
| await createTransaction({ | |
| type: actionState.type, | |
| amount: parseFloat(amount), | |
| currency: targetWallet.currency, | |
| wallet_id: targetWallet.id, | |
| category: finalCategory, | |
| note: note || (actionState.type === 'income' ? 'Added Income' : 'Recorded Expense'), | |
| date: new Date().toISOString() | |
| } as any); | |
| } else if (actionState?.type === 'exchange') { | |
| const sourceW = wallets.find((w: any) => w.id === actionState.walletId); | |
| const destW = wallets.find((w: any) => w.id.toString() === toWallet); | |
| if (!sourceW || !destW) return; | |
| const fAmt = parseFloat(amount); | |
| const tAmt = parseFloat(toAmount); | |
| await createExchange({ | |
| from_amount: fAmt, | |
| from_currency: sourceW.currency, | |
| from_wallet_id: sourceW.id, | |
| to_amount: tAmt, | |
| to_currency: destW.currency, | |
| to_wallet_id: destW.id, | |
| rate: tAmt / fAmt, | |
| note: note || `Exchanged to ${destW.name}`, | |
| date: new Date().toISOString() | |
| } as any); | |
| } | |
| setActionState(null); | |
| setAmount(''); setToAmount(''); setNote(''); setToWallet(''); setCategory('other'); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| return ( | |
| <div className="p-4 md:p-8 h-full flex flex-col"> | |
| <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6 md:mb-8 shrink-0"> | |
| <h1 className="text-xl md:text-2xl font-bold flex items-center gap-3"> | |
| <WalletIcon className="w-5 h-5 md:w-6 md:h-6 text-indigo-400" /> Wallets | |
| </h1> | |
| <Button onClick={() => setActionState(null)} className="opacity-0 cursor-default pointer-events-none w-full sm:w-auto"> | |
| Placeholder | |
| </Button> | |
| </div> | |
| {actionState && ( | |
| <div className={`glass-panel p-4 md:p-6 mb-6 md:mb-8 border ${ | |
| actionState.type === 'income' ? 'border-emerald-500/30' : | |
| actionState.type === 'expense' ? 'border-rose-500/30' : | |
| 'border-blue-500/30' | |
| }`}> | |
| <div className="flex justify-between items-center mb-4"> | |
| <h2 className={`text-lg font-semibold ${ | |
| actionState.type === 'income' ? 'text-emerald-400' : | |
| actionState.type === 'expense' ? 'text-rose-400' : | |
| 'text-blue-400' | |
| }`}> | |
| {actionState.type === 'income' ? `Add Salary / Income to ${wallets.find((w: any) => w.id === actionState.walletId)?.name}` : | |
| actionState.type === 'expense' ? `Add Expense from ${wallets.find((w: any) => w.id === actionState.walletId)?.name}` : | |
| `Exchange from ${wallets.find((w: any) => w.id === actionState.walletId)?.name}`} | |
| </h2> | |
| <Button variant="ghost" size="sm" onClick={() => setActionState(null)}>Cancel</Button> | |
| </div> | |
| <form onSubmit={handleSubmit} className="space-y-4"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {actionState.type === 'exchange' && (() => { | |
| const sourceName = wallets.find((w: any) => w.id === actionState.walletId)?.name || ''; | |
| const allowedNames = getAllowedDestinations(sourceName); | |
| const availableDestinations = wallets.filter((w: any) => w.id !== actionState.walletId && allowedNames.includes(w.name)); | |
| return ( | |
| <div> | |
| <Label>To Wallet</Label> | |
| <Select required value={toWallet} onChange={e => setToWallet(e.target.value)} className="mt-1"> | |
| <option value="">Select Destination</option> | |
| {availableDestinations.map((w: any) => ( | |
| <option key={w.id} value={w.id}>{w.name} ({w.currency})</option> | |
| ))} | |
| </Select> | |
| {availableDestinations.length === 0 && ( | |
| <p className="text-xs text-red-400 mt-2">No allowed conversions for this wallet type.</p> | |
| )} | |
| </div> | |
| ); | |
| })()} | |
| <div> | |
| <Label>{ | |
| actionState.type === 'exchange' ? 'Amount to Exchange' : | |
| actionState.type === 'income' ? 'Income Amount' : | |
| 'Expense Amount' | |
| }</Label> | |
| <div className="relative mt-1"> | |
| <AmountInput required value={amount} onChange={e => setAmount(e.target.value)} className="pr-12" /> | |
| <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-neutral-500 font-medium text-sm"> | |
| {wallets.find((w: any) => w.id === actionState.walletId)?.currency} | |
| </div> | |
| </div> | |
| </div> | |
| {actionState.type === 'exchange' && ( | |
| <div> | |
| <Label>Exact Amount Received</Label> | |
| <div className="relative mt-1"> | |
| <AmountInput required value={toAmount} onChange={e => setToAmount(e.target.value)} className="pr-12" /> | |
| <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-neutral-500 font-medium text-sm"> | |
| {wallets.find((w: any) => w.id.toString() === toWallet)?.currency} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {actionState.type === 'expense' && ( | |
| <div> | |
| <Label>Category</Label> | |
| <Select | |
| value={category} | |
| onChange={e => setCategory(e.target.value)} | |
| className="mt-1" | |
| > | |
| <option value="food">Food</option> | |
| <option value="market">Market</option> | |
| <option value="transport">Transport</option> | |
| <option value="cafe">Cafe</option> | |
| <option value="barber">Barber</option> | |
| <option value="mobile-balance">Mobile Balance</option> | |
| <option value="electricity">Electricity</option> | |
| <option value="health">Health</option> | |
| <option value="gift">Gift</option> | |
| <option value="other">Other</option> | |
| </Select> | |
| </div> | |
| )} | |
| {actionState.type === 'income' && ( | |
| <div> | |
| <Label>Category</Label> | |
| <Select | |
| value={category} | |
| onChange={e => setCategory(e.target.value)} | |
| className="mt-1" | |
| > | |
| <option value="salary">Salary</option> | |
| <option value="gift">Gift</option> | |
| <option value="other">Other</option> | |
| </Select> | |
| </div> | |
| )} | |
| <div className={actionState.type === 'exchange' ? "col-span-1 md:col-span-2" : ""}> | |
| <Label>Note (Optional)</Label> | |
| <Input type="text" value={note} onChange={e => setNote(e.target.value)} placeholder={ | |
| actionState.type === 'income' ? 'e.g. Salary' : | |
| actionState.type === 'expense' ? 'e.g. Food, Transport' : | |
| '' | |
| } className="mt-1" /> | |
| </div> | |
| </div> | |
| {isInsufficient && ( | |
| <div className="bg-rose-500/10 border border-rose-500/20 text-rose-400 p-3 rounded-xl text-sm mb-4"> | |
| cantbe expense balnce than your balance | |
| </div> | |
| )} | |
| <div className="flex justify-end pt-2 md:pt-4"> | |
| <Button | |
| type="submit" | |
| disabled={isSubmitting || isInsufficient} | |
| className={`w-full md:w-auto ${ | |
| isInsufficient ? 'bg-neutral-800 text-neutral-500 cursor-not-allowed' : | |
| actionState.type === 'income' ? 'bg-emerald-600 hover:bg-emerald-500' : | |
| actionState.type === 'expense' ? 'bg-rose-600 hover:bg-rose-500' : | |
| 'bg-blue-600 hover:bg-blue-500' | |
| } text-white`} | |
| > | |
| {actionState.type === 'income' ? 'Add Funds' : | |
| actionState.type === 'expense' ? 'Record Expense' : | |
| 'Execute Exchange'} | |
| </Button> | |
| </div> | |
| </form> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 overflow-y-auto pb-24 md:pb-0 pr-2"> | |
| {wallets.map((w: any) => { | |
| const balance = getBalance(w); | |
| const { icon: Icon, color, bg, border } = getWalletStyling(w.name); | |
| return ( | |
| <div key={w.id} className={`glass-panel p-4 md:p-6 border border-white/5 hover:border-indigo-500/30 transition-all flex flex-col justify-between group`}> | |
| <div> | |
| <div className={`w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center mb-3 md:mb-4 border ${bg} ${color} ${border}`}> | |
| <Icon className="w-5 h-5 md:w-6 md:h-6" /> | |
| </div> | |
| <h3 className="text-base md:text-lg font-bold">{w.name}</h3> | |
| <p className="text-xs md:text-sm text-neutral-400 capitalize">{w.type}</p> | |
| </div> | |
| <div className="mt-6 md:mt-8 pt-4 border-t border-white/10"> | |
| <p className="text-xs md:text-sm text-neutral-500 mb-1">Current Balance</p> | |
| <p className="text-xl md:text-2xl font-bold tracking-tight mb-4"> | |
| {balance.toLocaleString(undefined, { maximumFractionDigits: 2 })} <span className="text-sm md:text-lg text-neutral-500">{w.currency}</span> | |
| </p> | |
| <div className="grid grid-cols-3 gap-1 md:gap-2 mt-auto"> | |
| <button onClick={() => { setActionState({ type: 'income', walletId: w.id }); setCategory('salary'); }} className="flex items-center justify-center gap-1 md:gap-1.5 py-2 rounded shadow-sm bg-white/5 hover:bg-emerald-500/20 text-emerald-400 hover:text-emerald-300 font-medium text-[10px] md:text-sm transition-colors border border-emerald-500/10 hover:border-emerald-500/30"> | |
| <PlusCircle className="w-3 h-3 md:w-4 md:h-4" /> Add | |
| </button> | |
| <button onClick={() => { setActionState({ type: 'expense', walletId: w.id }); setCategory('other'); }} className="flex items-center justify-center gap-1 md:gap-1.5 py-2 rounded shadow-sm bg-white/5 hover:bg-rose-500/20 text-rose-400 hover:text-rose-300 font-medium text-[10px] md:text-sm transition-colors border border-rose-500/10 hover:border-rose-500/30"> | |
| <MinusCircle className="w-3 h-3 md:w-4 md:h-4" /> Expense | |
| </button> | |
| <button onClick={() => { setActionState({ type: 'exchange', walletId: w.id }); setCategory('other'); }} className="flex items-center justify-center gap-1 md:gap-1.5 py-2 rounded shadow-sm bg-white/5 hover:bg-blue-500/20 text-blue-400 hover:text-blue-300 font-medium text-[10px] md:text-sm transition-colors border border-blue-500/10 hover:border-blue-500/30"> | |
| <RefreshCw className="w-3 h-3 md:w-3.5 md:h-3.5" /> Exch | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |