4974f88e8c
- Backend: billing module with ServicePlan, PricingRule, Wallet, WalletTransaction entities - Admin can CRUD service plans with hourly/monthly/yearly billing cycles - Each plan has flexible pricing rules (base_fee, cpu, memory, storage, db addon) - Wallet system: auto-created per user, charge, deduct, refund, transaction history - Cost calculation endpoint: cross-cycle conversion (hourly*720=monthly, monthly*12=yearly) - Frontend: admin billing management page (/dashboard/admin/billing) - Frontend: user wallet page with balance, quick-charge, transaction history - Deploy page: cost breakdown shown in Review step (hourly/monthly/yearly) - Navigation: Wallet link for users, Billing Plans link for admins
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import api from '@/lib/api';
|
||
import { toast } from 'react-toastify';
|
||
import type { WalletTransaction, TransactionType } from '@/types';
|
||
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock } from 'lucide-react';
|
||
|
||
const txTypeLabels: Record<TransactionType, string> = {
|
||
charge: 'شارژ',
|
||
deduction: 'کسر',
|
||
refund: 'بازگشت',
|
||
};
|
||
|
||
const txTypeColors: Record<TransactionType, string> = {
|
||
charge: 'text-green-600',
|
||
deduction: 'text-red-600',
|
||
refund: 'text-blue-600',
|
||
};
|
||
|
||
const txTypeIcons: Record<TransactionType, React.ReactNode> = {
|
||
charge: <ArrowDownCircle className="w-4 h-4 text-green-500" />,
|
||
deduction: <ArrowUpCircle className="w-4 h-4 text-red-500" />,
|
||
refund: <RotateCcw className="w-4 h-4 text-blue-500" />,
|
||
};
|
||
|
||
export default function WalletPage() {
|
||
const queryClient = useQueryClient();
|
||
const [chargeAmount, setChargeAmount] = useState('');
|
||
const [showCharge, setShowCharge] = useState(false);
|
||
|
||
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
|
||
queryKey: ['wallet-balance'],
|
||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||
});
|
||
|
||
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
|
||
queryKey: ['wallet-transactions'],
|
||
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
||
});
|
||
|
||
const chargeMutation = useMutation({
|
||
mutationFn: (amount: number) =>
|
||
api.post('/billing/wallet/charge', { amount, description: 'شارژ کیف پول' }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||
toast.success('کیف پول شارژ شد');
|
||
setChargeAmount('');
|
||
setShowCharge(false);
|
||
},
|
||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در شارژ'),
|
||
});
|
||
|
||
const handleCharge = () => {
|
||
const amount = Number(chargeAmount);
|
||
if (!amount || amount < 1000) {
|
||
toast.error('حداقل مبلغ شارژ ۱,۰۰۰ تومان');
|
||
return;
|
||
}
|
||
chargeMutation.mutate(amount);
|
||
};
|
||
|
||
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
|
||
const formatDate = (d: string) => new Date(d).toLocaleDateString('fa-IR', {
|
||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||
});
|
||
|
||
return (
|
||
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||
<div>
|
||
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> کیف پول</h1>
|
||
<p className="page-subtitle">مدیریت موجودی و تراکنشها</p>
|
||
</div>
|
||
|
||
{/* Balance Card */}
|
||
<div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm opacity-80">موجودی فعلی</p>
|
||
<p className="text-3xl font-bold mt-1">
|
||
{walletLoading ? '...' : `${formatPrice(walletData?.balance ?? 0)}`}
|
||
<span className="text-lg font-normal mr-2">تومان</span>
|
||
</p>
|
||
</div>
|
||
{!showCharge && (
|
||
<button
|
||
onClick={() => setShowCharge(true)}
|
||
className="flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-semibold transition-colors"
|
||
>
|
||
<Plus className="w-4 h-4" /> شارژ کیف پول
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{showCharge && (
|
||
<div className="mt-4 pt-4 border-t border-white/20 flex items-center gap-3">
|
||
<input
|
||
type="number"
|
||
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
|
||
placeholder="مبلغ (تومان) — حداقل ۱,۰۰۰"
|
||
value={chargeAmount}
|
||
onChange={(e) => setChargeAmount(e.target.value)}
|
||
min={1000}
|
||
/>
|
||
<button
|
||
onClick={handleCharge}
|
||
disabled={chargeMutation.isPending}
|
||
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||
>
|
||
{chargeMutation.isPending ? '...' : 'پرداخت'}
|
||
</button>
|
||
<button
|
||
onClick={() => setShowCharge(false)}
|
||
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
|
||
>
|
||
انصراف
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick charge amounts */}
|
||
{showCharge && (
|
||
<div className="flex gap-2 mt-3">
|
||
{[10000, 50000, 100000, 500000].map((amt) => (
|
||
<button
|
||
key={amt}
|
||
onClick={() => setChargeAmount(String(amt))}
|
||
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg text-xs font-medium transition-colors"
|
||
>
|
||
{amt.toLocaleString('fa-IR')} ت
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Transactions */}
|
||
<div className="card">
|
||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||
<Clock className="w-5 h-5 text-gray-400" /> تاریخچه تراکنشها
|
||
</h2>
|
||
|
||
{txLoading ? (
|
||
<div className="text-center py-8 text-gray-400">در حال بارگذاری...</div>
|
||
) : transactions.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-400">هنوز تراکنشی ثبت نشده</div>
|
||
) : (
|
||
<div className="divide-y divide-gray-100">
|
||
{transactions.map((tx) => (
|
||
<div key={tx.id} className="flex items-center justify-between py-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-8 h-8 rounded-full bg-gray-50 flex items-center justify-center">
|
||
{txTypeIcons[tx.type]}
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium text-gray-900">
|
||
{txTypeLabels[tx.type]}
|
||
{tx.description && <span className="text-gray-500 font-normal"> — {tx.description}</span>}
|
||
</p>
|
||
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-left">
|
||
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
|
||
{tx.type === 'deduction' ? '−' : '+'}{formatPrice(tx.amount)} ت
|
||
</p>
|
||
<p className="text-xs text-gray-400">مانده: {formatPrice(tx.balanceAfter)} ت</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|