- 40+ styling fixes across all 11 pages (consistent padding, titles, empty states, date formatting) - Command Palette (Ctrl+K) with 11 nav items + 3 actions - Breadcrumbs with hash-based path detection and mobile truncation - Activity Feed component with 11 color-coded action icons and 30s auto-refresh - ExportButton (CSV) on Purchases, Audit, Users pages - SortableHeader with 3-state toggle on Purchases and Audit pages - copyToClipboard utility with navigator.clipboard + fallback - Notification badge on Purchases sidebar item (pending count) - Connection status indicator in sidebar footer - Removed dynamic imports from page.tsx (reduces memory) - Disabled Prisma query logging (reduces memory) - All components verified: named exports, @/ prefix, hash navigation, sonner toasts
914 lines
39 KiB
TypeScript
914 lines
39 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { toast } from 'sonner';
|
||
import { useAuthStore } from '@/stores/auth-store';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
AlertDialogTrigger,
|
||
} from '@/components/ui/alert-dialog';
|
||
import {
|
||
Wallet,
|
||
Users,
|
||
Eye,
|
||
EyeOff,
|
||
Search,
|
||
RefreshCw,
|
||
DollarSign,
|
||
Download,
|
||
Lock,
|
||
Copy,
|
||
AlertTriangle,
|
||
ShieldCheck,
|
||
} from 'lucide-react';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
|
||
// ── Types ──────────────────────────────────────────────
|
||
|
||
interface WalletUser {
|
||
id: number;
|
||
username: string | null;
|
||
telegramId: string;
|
||
status: number;
|
||
totalBalance: number;
|
||
bonusBalance: number;
|
||
walletCount: number;
|
||
country: string | null;
|
||
city: string | null;
|
||
}
|
||
|
||
interface UserWallet {
|
||
id: number;
|
||
walletType: string;
|
||
address: string;
|
||
balance: number;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface UserProfile {
|
||
id: number;
|
||
username: string | null;
|
||
telegramId: string;
|
||
status: number;
|
||
totalBalance: number;
|
||
bonusBalance: number;
|
||
country: string | null;
|
||
city: string | null;
|
||
createdAt: string;
|
||
wallets: UserWallet[];
|
||
}
|
||
|
||
interface OverviewData {
|
||
totals: Record<string, number>;
|
||
walletCounts: Record<string, number>;
|
||
totalUsd: number;
|
||
totalWallets: number;
|
||
totalUsers: number;
|
||
commissionEnabled: boolean;
|
||
commissionRate: number;
|
||
currentCommission: number;
|
||
payments: {
|
||
id: number;
|
||
paidAmountUsd: number;
|
||
note: string | null;
|
||
createdAt: string;
|
||
}[];
|
||
lastPaidAmount: number;
|
||
commissionDue: number;
|
||
}
|
||
|
||
interface SeedEntry {
|
||
walletId: number;
|
||
userId: number;
|
||
username: string;
|
||
walletType: string;
|
||
address: string;
|
||
derivationPath: string;
|
||
mnemonic: string;
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────
|
||
|
||
function formatBalance(val: number): string {
|
||
return val.toFixed(8);
|
||
}
|
||
|
||
function truncateAddress(addr: string, len = 10): string {
|
||
if (addr.length <= len * 2) return addr;
|
||
return `${addr.slice(0, len)}...${addr.slice(-len)}`;
|
||
}
|
||
|
||
function statusBadge(status: number) {
|
||
if (status === 0) return <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">Active</Badge>;
|
||
if (status === 2) return <Badge className="bg-red-100 text-red-700 hover:bg-red-100">Blocked</Badge>;
|
||
return <Badge variant="secondary">Unknown</Badge>;
|
||
}
|
||
|
||
function walletTypeBadge(type: string) {
|
||
const colorMap: Record<string, string> = {
|
||
BTC: 'bg-orange-100 text-orange-700 hover:bg-orange-100',
|
||
LTC: 'bg-gray-100 text-gray-700 hover:bg-gray-100',
|
||
ETH: 'bg-violet-100 text-violet-700 hover:bg-violet-100',
|
||
USDT: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100',
|
||
USDC: 'bg-blue-100 text-blue-700 hover:bg-blue-100',
|
||
};
|
||
return <Badge className={colorMap[type] || ''}>{type}</Badge>;
|
||
}
|
||
|
||
function copyToClipboard(text: string, label: string) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
toast.success(`${label} copied!`);
|
||
}).catch(() => {
|
||
toast.error('Failed to copy');
|
||
});
|
||
}
|
||
|
||
// ── Component ───────────────────────────────────────────
|
||
|
||
export function WalletsPage() {
|
||
const { role } = useAuthStore();
|
||
const isSuperAdmin = role === 'super_admin';
|
||
|
||
// Tab 1 state
|
||
const [userList, setUserList] = useState<WalletUser[]>([]);
|
||
const [userListLoading, setUserListLoading] = useState(true);
|
||
const [userSearch, setUserSearch] = useState('');
|
||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||
const [selectedUser, setSelectedUser] = useState<UserProfile | null>(null);
|
||
const [selectedUserLoading, setSelectedUserLoading] = useState(false);
|
||
|
||
// Tab 2 state
|
||
const [overview, setOverview] = useState<OverviewData | null>(null);
|
||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||
const [paymentAmount, setPaymentAmount] = useState('');
|
||
const [paymentNote, setPaymentNote] = useState('');
|
||
const [paymentSubmitting, setPaymentSubmitting] = useState(false);
|
||
|
||
// Tab 3 state
|
||
const [seeds, setSeeds] = useState<SeedEntry[]>([]);
|
||
const [seedsLoading, setSeedsLoading] = useState(false);
|
||
const [seedsLoaded, setSeedsLoaded] = useState(false);
|
||
const [revealedMnemonics, setRevealedMnemonics] = useState<Set<number>>(new Set());
|
||
const [seedsAlertOpen, setSeedsAlertOpen] = useState(false);
|
||
const [exportAlertOpen, setExportAlertOpen] = useState(false);
|
||
|
||
// ── Tab 1: User Wallets ────────────────────────────
|
||
|
||
const fetchUserList = useCallback(async (search?: string) => {
|
||
setUserListLoading(true);
|
||
try {
|
||
const query = search ? `?search=${encodeURIComponent(search)}` : '';
|
||
const res = await fetch(`/api/wallets/bulk${query}`);
|
||
if (!res.ok) throw new Error('Failed to fetch');
|
||
const data = await res.json();
|
||
setUserList(data);
|
||
} catch {
|
||
toast.error('Failed to load user list');
|
||
} finally {
|
||
setUserListLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const debounce = setTimeout(() => {
|
||
fetchUserList(userSearch);
|
||
}, 300);
|
||
return () => clearTimeout(debounce);
|
||
}, [userSearch, fetchUserList]);
|
||
|
||
const selectUser = async (userId: number) => {
|
||
setSelectedUserId(userId);
|
||
setSelectedUser(null);
|
||
setSelectedUserLoading(true);
|
||
try {
|
||
const res = await fetch(`/api/wallets/${userId}`);
|
||
if (!res.ok) throw new Error('Failed to fetch');
|
||
const data = await res.json();
|
||
setSelectedUser(data);
|
||
} catch {
|
||
toast.error('Failed to load user wallets');
|
||
} finally {
|
||
setSelectedUserLoading(false);
|
||
}
|
||
};
|
||
|
||
// ── Tab 2: Owner Summary ──────────────────────────
|
||
|
||
useEffect(() => {
|
||
const fetchOverview = async () => {
|
||
setOverviewLoading(true);
|
||
try {
|
||
const res = await fetch('/api/wallets/overview');
|
||
if (!res.ok) throw new Error('Failed to fetch');
|
||
const data = await res.json();
|
||
setOverview(data);
|
||
} catch {
|
||
toast.error('Failed to load overview');
|
||
} finally {
|
||
setOverviewLoading(false);
|
||
}
|
||
};
|
||
fetchOverview();
|
||
}, []);
|
||
|
||
const handleRecordPayment = async () => {
|
||
const amount = parseFloat(paymentAmount);
|
||
if (isNaN(amount) || amount <= 0) {
|
||
toast.error('Enter a valid amount');
|
||
return;
|
||
}
|
||
setPaymentSubmitting(true);
|
||
try {
|
||
const res = await fetch('/api/wallets/record-payment', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ paidAmount: amount, note: paymentNote }),
|
||
});
|
||
if (!res.ok) throw new Error('Failed');
|
||
toast.success('Payment recorded successfully');
|
||
setPaymentAmount('');
|
||
setPaymentNote('');
|
||
// Refetch overview
|
||
const overviewRes = await fetch('/api/wallets/overview');
|
||
if (overviewRes.ok) {
|
||
const data = await overviewRes.json();
|
||
setOverview(data);
|
||
}
|
||
} catch {
|
||
toast.error('Failed to record payment');
|
||
} finally {
|
||
setPaymentSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// ── Tab 3: Seed Phrases ────────────────────────────
|
||
|
||
const handleLoadSeeds = async () => {
|
||
setSeedsLoading(true);
|
||
try {
|
||
const res = await fetch('/api/wallets/seeds');
|
||
if (!res.ok) throw new Error('Failed');
|
||
const data = await res.json();
|
||
setSeeds(data);
|
||
setSeedsLoaded(true);
|
||
toast.success(`Loaded ${data.length} seed phrases`);
|
||
} catch {
|
||
toast.error('Failed to load seeds');
|
||
} finally {
|
||
setSeedsLoading(false);
|
||
setSeedsAlertOpen(false);
|
||
}
|
||
};
|
||
|
||
const handleExportSeeds = () => {
|
||
window.open('/api/wallets/export-seeds', '_blank');
|
||
setExportAlertOpen(false);
|
||
};
|
||
|
||
const toggleMnemonic = (walletId: number) => {
|
||
setRevealedMnemonics((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(walletId)) {
|
||
next.delete(walletId);
|
||
} else {
|
||
next.add(walletId);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// ── Render ─────────────────────────────────────────
|
||
|
||
const currencyTypes = ['BTC', 'LTC', 'ETH', 'USDT', 'USDC'];
|
||
|
||
return (
|
||
<div className="p-4 md:p-6 space-y-6">
|
||
<div className="flex items-center gap-3">
|
||
<Wallet className="h-8 w-8 text-orange-500" />
|
||
<div>
|
||
<h2 className="text-xl font-semibold">Wallets</h2>
|
||
<p className="text-sm text-muted-foreground">Manage crypto wallets, view balances, and track commissions</p>
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs defaultValue="user-wallets" className="w-full">
|
||
<TabsList className="grid w-full grid-cols-3 max-w-lg">
|
||
<TabsTrigger value="user-wallets" className="gap-2">
|
||
<Users className="h-4 w-4" />
|
||
User Wallets
|
||
</TabsTrigger>
|
||
<TabsTrigger value="owner-summary" className="gap-2">
|
||
<DollarSign className="h-4 w-4" />
|
||
Owner Summary
|
||
</TabsTrigger>
|
||
{isSuperAdmin && (
|
||
<TabsTrigger value="seed-phrases" className="gap-2">
|
||
<ShieldCheck className="h-4 w-4" />
|
||
Seeds
|
||
</TabsTrigger>
|
||
)}
|
||
</TabsList>
|
||
|
||
{/* ── Tab 1: User Wallets ─────────────────────── */}
|
||
<TabsContent value="user-wallets" className="mt-6">
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Left: User List */}
|
||
<Card className="lg:col-span-1">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-lg">Users with Wallets</CardTitle>
|
||
<div className="relative mt-2">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Search username or Telegram ID..."
|
||
className="pl-9"
|
||
value={userSearch}
|
||
onChange={(e) => setUserSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto">
|
||
{userListLoading ? (
|
||
<div className="p-4 space-y-3">
|
||
{[1, 2, 3, 4, 5].map((i) => (
|
||
<div key={i} className="flex items-center justify-between p-3">
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-4 w-24" />
|
||
<Skeleton className="h-3 w-16" />
|
||
</div>
|
||
<Skeleton className="h-5 w-12" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : userList.length === 0 ? (
|
||
<div className="p-8 text-center text-muted-foreground">
|
||
<Users className="h-10 w-10 mx-auto mb-2 opacity-40" />
|
||
<p>No users with wallets found</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y">
|
||
{userList.map((u) => (
|
||
<button
|
||
key={u.id}
|
||
className={`w-full text-left px-4 py-3 flex items-center justify-between hover:bg-muted/50 transition-colors ${
|
||
selectedUserId === u.id ? 'bg-muted border-l-2 border-l-orange-500' : ''
|
||
}`}
|
||
onClick={() => selectUser(u.id)}
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-sm truncate">
|
||
{u.username || `User#${u.id}`}
|
||
</span>
|
||
{statusBadge(u.status)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
TG: {u.telegramId} · {u.country ? `${u.country}${u.city ? `, ${u.city}` : ''}` : 'N/A'}
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline" className="ml-2 shrink-0">
|
||
{u.walletCount} wallet{u.walletCount !== 1 ? 's' : ''}
|
||
</Badge>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Right: Selected User Details */}
|
||
<div className="lg:col-span-2 space-y-4">
|
||
{selectedUserLoading && (
|
||
<>
|
||
<Card>
|
||
<CardHeader>
|
||
<Skeleton className="h-6 w-40" />
|
||
<Skeleton className="h-4 w-60" />
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<Skeleton key={i} className="h-16" />
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-4 space-y-3">
|
||
{[1, 2, 3].map((i) => (
|
||
<Skeleton key={i} className="h-10 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
|
||
{!selectedUserLoading && !selectedUser && (
|
||
<Card className="flex items-center justify-center min-h-[300px]">
|
||
<CardContent className="text-center py-16">
|
||
<Wallet className="h-12 w-12 mx-auto mb-3 text-muted-foreground/40" />
|
||
<p className="text-muted-foreground">Select a user from the list to view their wallets</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{!selectedUserLoading && selectedUser && (
|
||
<>
|
||
{/* Profile Card */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-lg">
|
||
{selectedUser.username || `User#${selectedUser.id}`}
|
||
</CardTitle>
|
||
<div className="flex items-center gap-2">
|
||
{statusBadge(selectedUser.status)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-1"
|
||
onClick={() => {
|
||
toast.success('Balances refreshed');
|
||
}}
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
Refresh Balances
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<CardDescription>Telegram ID: {selectedUser.telegramId}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">Main Balance</p>
|
||
<p className="text-lg font-semibold">${selectedUser.totalBalance.toFixed(2)}</p>
|
||
</div>
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">Bonus Balance</p>
|
||
<p className="text-lg font-semibold">${selectedUser.bonusBalance.toFixed(2)}</p>
|
||
</div>
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">Country</p>
|
||
<p className="text-sm font-medium mt-0.5">{selectedUser.country || '—'}</p>
|
||
</div>
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">City</p>
|
||
<p className="text-sm font-medium mt-0.5">{selectedUser.city || '—'}</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Wallets Table */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-lg">Wallets ({selectedUser.wallets.length})</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
{selectedUser.wallets.length === 0 ? (
|
||
<div className="p-8 text-center text-muted-foreground">
|
||
<p>No wallets found for this user</p>
|
||
</div>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Type</TableHead>
|
||
<TableHead>Address</TableHead>
|
||
<TableHead className="text-right">Balance</TableHead>
|
||
<TableHead className="text-right">Created</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{selectedUser.wallets.map((w) => (
|
||
<TableRow key={w.id}>
|
||
<TableCell>{walletTypeBadge(w.walletType)}</TableCell>
|
||
<TableCell>
|
||
<button
|
||
className="font-mono text-xs hover:text-orange-500 transition-colors cursor-pointer"
|
||
onClick={() => copyToClipboard(w.address, 'Address')}
|
||
title="Click to copy"
|
||
>
|
||
{truncateAddress(w.address)}
|
||
</button>
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono text-sm">
|
||
{formatBalance(w.balance)}
|
||
</TableCell>
|
||
<TableCell className="text-right text-xs text-muted-foreground">
|
||
{new Date(w.createdAt).toLocaleDateString()}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* ── Tab 2: Owner Summary ────────────────────── */}
|
||
<TabsContent value="owner-summary" className="mt-6 space-y-6">
|
||
{overviewLoading ? (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{[1, 2, 3, 4].map((i) => (
|
||
<Skeleton key={i} className="h-28" />
|
||
))}
|
||
</div>
|
||
) : overview ? (
|
||
<>
|
||
{/* KPI Cards */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm text-muted-foreground">Total USD Value</p>
|
||
<DollarSign className="h-4 w-4 text-orange-500" />
|
||
</div>
|
||
<p className="text-2xl font-bold mt-1">${overview.totalUsd.toFixed(2)}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm text-muted-foreground">Total Users</p>
|
||
<Users className="h-4 w-4 text-emerald-500" />
|
||
</div>
|
||
<p className="text-2xl font-bold mt-1">{overview.totalUsers}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm text-muted-foreground">Active Wallets</p>
|
||
<Wallet className="h-4 w-4 text-violet-500" />
|
||
</div>
|
||
<p className="text-2xl font-bold mt-1">{overview.totalWallets}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm text-muted-foreground">Commission Due</p>
|
||
<DollarSign className="h-4 w-4 text-red-500" />
|
||
</div>
|
||
<p className="text-2xl font-bold mt-1">${overview.commissionDue.toFixed(2)}</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Balances by Currency */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Balances by Currency</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Currency</TableHead>
|
||
<TableHead className="text-right">Wallet Count</TableHead>
|
||
<TableHead className="text-right">Total Balance</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{currencyTypes.map((type) => (
|
||
<TableRow key={type}>
|
||
<TableCell>{walletTypeBadge(type)}</TableCell>
|
||
<TableCell className="text-right">
|
||
{overview.walletCounts[type] || 0}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono text-sm">
|
||
{formatBalance(overview.totals[type] || 0)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Commission Section */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Commission Management</CardTitle>
|
||
<CardDescription>
|
||
Current rate: {(overview.commissionRate * 100).toFixed(0)}%
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{/* Commission Stats */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">Rate</p>
|
||
<p className="text-lg font-semibold">{(overview.commissionRate * 100).toFixed(0)}%</p>
|
||
</div>
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">Total Balance</p>
|
||
<p className="text-lg font-semibold">${overview.totalUsd.toFixed(2)}</p>
|
||
</div>
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">Commission Amount</p>
|
||
<p className="text-lg font-semibold">${overview.currentCommission.toFixed(2)}</p>
|
||
</div>
|
||
<div className="rounded-lg border p-3">
|
||
<p className="text-xs text-muted-foreground">Last Paid</p>
|
||
<p className="text-lg font-semibold">${overview.lastPaidAmount.toFixed(2)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 p-3 rounded-lg border bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300">
|
||
<DollarSign className="h-4 w-4 shrink-0" />
|
||
<span className="text-sm font-medium">Commission Due: ${overview.commissionDue.toFixed(2)}</span>
|
||
</div>
|
||
|
||
{/* Record Payment Form */}
|
||
<div className="space-y-3">
|
||
<h4 className="text-sm font-medium">Record Payment</h4>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
<div>
|
||
<Label htmlFor="payment-amount">Amount (USD)</Label>
|
||
<Input
|
||
id="payment-amount"
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
placeholder="0.00"
|
||
value={paymentAmount}
|
||
onChange={(e) => setPaymentAmount(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="payment-note">Note</Label>
|
||
<Input
|
||
id="payment-note"
|
||
placeholder="Payment note (optional)"
|
||
value={paymentNote}
|
||
onChange={(e) => setPaymentNote(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-end">
|
||
<Button
|
||
onClick={handleRecordPayment}
|
||
disabled={paymentSubmitting}
|
||
className="w-full"
|
||
>
|
||
{paymentSubmitting ? 'Recording...' : 'Record Payment'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Payment History */}
|
||
<div>
|
||
<h4 className="text-sm font-medium mb-3">Payment History</h4>
|
||
{overview.payments.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||
No payment records yet
|
||
</div>
|
||
) : (
|
||
<div className="max-h-72 overflow-y-auto rounded-lg border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Date</TableHead>
|
||
<TableHead className="text-right">Amount (USD)</TableHead>
|
||
<TableHead>Note</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{overview.payments.map((p) => (
|
||
<TableRow key={p.id}>
|
||
<TableCell className="text-xs text-muted-foreground">
|
||
{new Date(p.createdAt).toLocaleDateString()}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
${p.paidAmountUsd.toFixed(2)}
|
||
</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">
|
||
{p.note || '—'}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
) : null}
|
||
</TabsContent>
|
||
|
||
{/* ── Tab 3: Seed Phrases ────────────────────── */}
|
||
<TabsContent value="seed-phrases" className="mt-6 space-y-4">
|
||
{isSuperAdmin && (
|
||
<>
|
||
{/* Commission Warning */}
|
||
{overview && overview.commissionDue > 0 && (
|
||
<div className="flex items-start gap-3 rounded-lg border border-orange-300 bg-orange-50 p-4 dark:bg-orange-950 dark:border-orange-800">
|
||
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-sm font-medium text-orange-700 dark:text-orange-300">
|
||
Outstanding Commission: ${overview.commissionDue.toFixed(2)}
|
||
</p>
|
||
<p className="text-xs text-orange-600 dark:text-orange-400 mt-1">
|
||
Please pay the outstanding commission before accessing seed phrases.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Action Buttons */}
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="flex flex-wrap gap-3">
|
||
<AlertDialog open={seedsAlertOpen} onOpenChange={setSeedsAlertOpen}>
|
||
<AlertDialogTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2"
|
||
disabled={overview && overview.commissionDue > 0}
|
||
>
|
||
<Lock className="h-4 w-4" />
|
||
{seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'}
|
||
</Button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>⚠️ Confirm Access to Seed Phrases</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
You are about to access sensitive cryptographic seed phrases. This action will be
|
||
logged in the audit trail. Only proceed if you are authorized.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleLoadSeeds}>
|
||
Proceed
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{seedsLoaded && (
|
||
<AlertDialog open={exportAlertOpen} onOpenChange={setExportAlertOpen}>
|
||
<AlertDialogTrigger asChild>
|
||
<Button variant="destructive" className="gap-2">
|
||
<Download className="h-4 w-4" />
|
||
Export All Seeds as CSV
|
||
</Button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>⚠️ Confirm CSV Export</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This will download all seed phrases as a CSV file containing sensitive data.
|
||
The export will be logged in the audit trail. Ensure you handle this file securely.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction onClick={handleExportSeeds}>
|
||
Export CSV
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Seeds Table */}
|
||
{seedsLoading && (
|
||
<Card>
|
||
<CardContent className="p-4 space-y-3">
|
||
{[1, 2, 3, 4].map((i) => (
|
||
<Skeleton key={i} className="h-12 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{!seedsLoading && seedsLoaded && (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-lg">Seed Phrases ({seeds.length})</CardTitle>
|
||
<CardDescription>Sensitive data — handle with care</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
{seeds.length === 0 ? (
|
||
<div className="p-8 text-center text-muted-foreground">
|
||
<Lock className="h-10 w-10 mx-auto mb-2 opacity-40" />
|
||
<p>No seed phrases found</p>
|
||
</div>
|
||
) : (
|
||
<div className="max-h-[calc(100vh-28rem)] overflow-y-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>User</TableHead>
|
||
<TableHead>Type</TableHead>
|
||
<TableHead>Address</TableHead>
|
||
<TableHead>Derivation</TableHead>
|
||
<TableHead>Mnemonic</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{seeds.map((s) => (
|
||
<TableRow key={s.walletId}>
|
||
<TableCell className="text-sm font-medium">
|
||
{s.username}
|
||
</TableCell>
|
||
<TableCell>{walletTypeBadge(s.walletType)}</TableCell>
|
||
<TableCell>
|
||
<button
|
||
className="font-mono text-xs hover:text-orange-500 transition-colors cursor-pointer"
|
||
onClick={() => copyToClipboard(s.address, 'Address')}
|
||
title="Click to copy"
|
||
>
|
||
{truncateAddress(s.address)}
|
||
</button>
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{s.derivationPath || '—'}
|
||
</TableCell>
|
||
<TableCell className="max-w-[280px]">
|
||
<div className="flex items-center gap-1">
|
||
<span className="font-mono text-xs truncate flex-1">
|
||
{revealedMnemonics.has(s.walletId)
|
||
? s.mnemonic
|
||
: '••••••••••••••••'}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 shrink-0"
|
||
onClick={() => toggleMnemonic(s.walletId)}
|
||
>
|
||
{revealedMnemonics.has(s.walletId) ? (
|
||
<EyeOff className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Eye className="h-3.5 w-3.5" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 shrink-0"
|
||
onClick={() => copyToClipboard(s.mnemonic, 'Mnemonic')}
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{!seedsLoading && !seedsLoaded && (
|
||
<Card className="flex items-center justify-center min-h-[300px]">
|
||
<CardContent className="text-center py-16">
|
||
<Lock className="h-12 w-12 mx-auto mb-3 text-muted-foreground/40" />
|
||
<p className="text-muted-foreground">
|
||
Click "Unlock & Load Seed Phrases" to view sensitive data
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
);
|
||
}
|