mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
feat: add partner management and withdrawal admin pages
- Admin partners page: list partners, view applications, approve/reject - Admin partner detail: stats, commission management, campaign assignment - Admin withdrawals page: list with risk scoring, status filters including cancelled - Admin withdrawal detail: fraud analysis, approve/reject/complete actions - Partner API client with full TypeScript interfaces - Withdrawal API client with admin and user endpoints - Referral page: partner application form, withdrawal balance and history - Navigation: partners and withdrawals in admin panel marketing group - i18n: full translations for ru, en, zh, fa locales - Neutral fallbacks for unknown status badges
This commit is contained in:
493
src/pages/AdminWithdrawalDetail.tsx
Normal file
493
src/pages/AdminWithdrawalDetail.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
// Locale mapping for formatting
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
|
||||
const formatDate = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Status badge config
|
||||
const statusBadgeConfig: Record<string, { labelKey: string; color: string; bgColor: string }> = {
|
||||
pending: {
|
||||
labelKey: 'admin.withdrawals.status.pending',
|
||||
color: 'text-yellow-400',
|
||||
bgColor: 'bg-yellow-500/20',
|
||||
},
|
||||
approved: {
|
||||
labelKey: 'admin.withdrawals.status.approved',
|
||||
color: 'text-accent-400',
|
||||
bgColor: 'bg-accent-500/20',
|
||||
},
|
||||
rejected: {
|
||||
labelKey: 'admin.withdrawals.status.rejected',
|
||||
color: 'text-error-400',
|
||||
bgColor: 'bg-error-500/20',
|
||||
},
|
||||
completed: {
|
||||
labelKey: 'admin.withdrawals.status.completed',
|
||||
color: 'text-success-400',
|
||||
bgColor: 'bg-success-500/20',
|
||||
},
|
||||
cancelled: {
|
||||
labelKey: 'admin.withdrawals.status.cancelled',
|
||||
color: 'text-dark-400',
|
||||
bgColor: 'bg-dark-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
// Risk score color helper
|
||||
function getRiskColor(score: number): { text: string; bg: string; bar: string } {
|
||||
if (score < 30)
|
||||
return { text: 'text-success-400', bg: 'bg-success-500/20', bar: 'bg-success-500' };
|
||||
if (score < 50) return { text: 'text-yellow-400', bg: 'bg-yellow-500/20', bar: 'bg-yellow-500' };
|
||||
if (score < 70) return { text: 'text-orange-400', bg: 'bg-orange-500/20', bar: 'bg-orange-500' };
|
||||
return { text: 'text-error-400', bg: 'bg-error-500/20', bar: 'bg-error-500' };
|
||||
}
|
||||
|
||||
// Risk level badge color
|
||||
function getRiskLevelColor(level: string): { text: string; bg: string } {
|
||||
switch (level) {
|
||||
case 'low':
|
||||
return { text: 'text-success-400', bg: 'bg-success-500/20' };
|
||||
case 'medium':
|
||||
return { text: 'text-yellow-400', bg: 'bg-yellow-500/20' };
|
||||
case 'high':
|
||||
return { text: 'text-orange-400', bg: 'bg-orange-500/20' };
|
||||
case 'critical':
|
||||
return { text: 'text-error-400', bg: 'bg-error-500/20' };
|
||||
default:
|
||||
return { text: 'text-dark-400', bg: 'bg-dark-500/20' };
|
||||
}
|
||||
}
|
||||
|
||||
// Type for parsed risk analysis
|
||||
interface RiskAnalysis {
|
||||
flags?: string[];
|
||||
balance_stats?: Record<string, unknown>;
|
||||
referral_deposits?: Record<string, unknown>;
|
||||
suspicious_referrals?: Record<string, unknown>;
|
||||
earnings_by_reason?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function AdminWithdrawalDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [rejectComment, setRejectComment] = useState('');
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||
|
||||
// Fetch detail
|
||||
const {
|
||||
data: detail,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['admin-withdrawal-detail', id],
|
||||
queryFn: () => withdrawalApi.getDetail(Number(id)),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: () => withdrawalApi.approve(Number(id)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (comment: string) => withdrawalApi.reject(Number(id), comment || undefined),
|
||||
onSuccess: () => {
|
||||
setShowRejectDialog(false);
|
||||
setRejectComment('');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
|
||||
},
|
||||
});
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: () => withdrawalApi.complete(Number(id)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawal-detail', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-withdrawals'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/withdrawals" />
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.withdrawals.detail.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
|
||||
<p className="text-error-400">{t('admin.withdrawals.detail.loadError')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/withdrawals')}
|
||||
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = statusBadgeConfig[detail.status] || statusBadgeConfig.cancelled;
|
||||
const riskColor = getRiskColor(detail.risk_score);
|
||||
|
||||
// Parse risk analysis
|
||||
const riskAnalysis = (detail.risk_analysis || {}) as RiskAnalysis;
|
||||
const flags = riskAnalysis.flags || [];
|
||||
|
||||
const riskLevelKey = detail.risk_level;
|
||||
const riskLevelBadge = getRiskLevelColor(riskLevelKey);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/withdrawals" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.withdrawals.detail.title')} #{detail.id}
|
||||
</h1>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${badge.bgColor} ${badge.color}`}>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
<span className="font-semibold text-dark-100">
|
||||
{formatWithCurrency(detail.amount_kopeks / 100, 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* User Info Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-4 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.userInfo')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.username')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{detail.username ? `@${detail.username}` : detail.first_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.telegramId')}
|
||||
</div>
|
||||
<div className="font-mono text-sm font-medium text-dark-200">
|
||||
{detail.telegram_id ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.balance')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{formatWithCurrency(detail.balance_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.totalReferrals')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">{detail.total_referrals}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.totalEarnings')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{formatWithCurrency(detail.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.createdAt')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{formatDate(detail.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Details Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.paymentDetails')}
|
||||
</h3>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<p className="whitespace-pre-wrap break-all text-sm text-dark-300">
|
||||
{detail.payment_details || t('admin.withdrawals.detail.noPaymentDetails')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Analysis Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-4 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.riskAnalysis')}
|
||||
</h3>
|
||||
|
||||
{/* Risk Score Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.riskScore')}
|
||||
</span>
|
||||
<span className={`text-lg font-bold ${riskColor.text}`}>{detail.risk_score}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${riskLevelBadge.bg} ${riskLevelBadge.text}`}
|
||||
>
|
||||
{t(`admin.withdrawals.detail.riskLevel.${riskLevelKey}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${riskColor.bar}`}
|
||||
style={{ width: `${Math.min(detail.risk_score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags */}
|
||||
{flags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.flags')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{flags.map((flag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 rounded-lg bg-error-500/10 px-3 py-2"
|
||||
>
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-error-300">{flag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{riskAnalysis.balance_stats && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.balanceStats')}
|
||||
</div>
|
||||
{Object.entries(riskAnalysis.balance_stats).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-xs">
|
||||
<span className="text-dark-500">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{riskAnalysis.referral_deposits && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.referralDeposits')}
|
||||
</div>
|
||||
{Object.entries(riskAnalysis.referral_deposits).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-xs">
|
||||
<span className="text-dark-500">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{riskAnalysis.suspicious_referrals && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.suspiciousReferrals')}
|
||||
</div>
|
||||
{Object.entries(riskAnalysis.suspicious_referrals).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-xs">
|
||||
<span className="text-dark-500">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{riskAnalysis.earnings_by_reason && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.withdrawals.detail.earningsByReason')}
|
||||
</div>
|
||||
{Object.entries(riskAnalysis.earnings_by_reason).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between text-xs">
|
||||
<span className="text-dark-500">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Comment Section */}
|
||||
{detail.admin_comment && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<h3 className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.adminComment')}
|
||||
</h3>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<p className="whitespace-pre-wrap text-sm text-dark-300">{detail.admin_comment}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processed At */}
|
||||
{detail.processed_at && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.processedAt')}
|
||||
</span>
|
||||
<span className="text-sm text-dark-200">{formatDate(detail.processed_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{detail.status === 'pending' && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => approveMutation.mutate()}
|
||||
disabled={approveMutation.isPending}
|
||||
className="flex-1 rounded-lg bg-success-500 px-4 py-3 font-medium text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
||||
>
|
||||
{approveMutation.isPending
|
||||
? t('admin.withdrawals.detail.approving')
|
||||
: t('admin.withdrawals.detail.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRejectDialog(true)}
|
||||
className="flex-1 rounded-lg bg-error-500 px-4 py-3 font-medium text-white transition-colors hover:bg-error-600"
|
||||
>
|
||||
{t('admin.withdrawals.detail.reject')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.status === 'approved' && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => completeMutation.mutate()}
|
||||
disabled={completeMutation.isPending}
|
||||
className="w-full rounded-lg bg-accent-500 px-4 py-3 font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{completeMutation.isPending
|
||||
? t('admin.withdrawals.detail.completing')
|
||||
: t('admin.withdrawals.detail.complete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reject Dialog */}
|
||||
{showRejectDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/60" onClick={() => setShowRejectDialog(false)} />
|
||||
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.withdrawals.detail.rejectTitle')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.rejectDescription')}
|
||||
</p>
|
||||
<textarea
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
placeholder={t('admin.withdrawals.detail.commentPlaceholder')}
|
||||
className="mb-4 w-full rounded-lg border border-dark-600 bg-dark-700 p-3 text-sm text-dark-200 placeholder:text-dark-500 focus:border-accent-500 focus:outline-none"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRejectDialog(false);
|
||||
setRejectComment('');
|
||||
}}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectMutation.mutate(rejectComment)}
|
||||
disabled={rejectMutation.isPending}
|
||||
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
{rejectMutation.isPending
|
||||
? t('admin.withdrawals.detail.rejecting')
|
||||
: t('admin.withdrawals.detail.confirmReject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user