- Install @tanstack/react-query with exponential backoff retry config - Create QueryClientProvider and custom hooks for listings, analytics, payments, and subscription API calls - Migrate 5 dashboard pages from useState/useEffect to React Query hooks - Add dark mode CSS variables and ThemeProvider with localStorage persistence - Add theme toggle button in dashboard header (sun/moon icon) - Enhance error boundaries with auto-retry, retry count, and loading state Co-Authored-By: Paperclip <noreply@paperclip.ing>
240 lines
9.1 KiB
TypeScript
240 lines
9.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Select } from '@/components/ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { useTransactions } from '@/lib/hooks/use-payments';
|
|
|
|
function formatVND(amount: string | number): string {
|
|
const num = typeof amount === 'string' ? Number(amount) : amount;
|
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ đ`;
|
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu đ`;
|
|
return num.toLocaleString('vi-VN') + ' đ';
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
|
PENDING: { label: 'Chờ xử lý', variant: 'secondary' },
|
|
PROCESSING: { label: 'Đang xử lý', variant: 'secondary' },
|
|
COMPLETED: { label: 'Thành công', variant: 'default' },
|
|
FAILED: { label: 'Thất bại', variant: 'destructive' },
|
|
REFUNDED: { label: 'Hoàn tiền', variant: 'outline' },
|
|
};
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
SUBSCRIPTION: 'Gói dịch vụ',
|
|
LISTING_FEE: 'Phí đăng tin',
|
|
DEPOSIT: 'Đặt cọc',
|
|
FEATURED_LISTING: 'Tin nổi bật',
|
|
};
|
|
|
|
const PROVIDER_LABELS: Record<string, string> = {
|
|
VNPAY: 'VNPay',
|
|
MOMO: 'MoMo',
|
|
ZALOPAY: 'ZaloPay',
|
|
BANK_TRANSFER: 'Chuyển khoản',
|
|
};
|
|
|
|
export default function PaymentsPage() {
|
|
const [statusFilter, setStatusFilter] = useState('');
|
|
const [page, setPage] = useState(0);
|
|
const limit = 20;
|
|
|
|
const { data: transactions, isLoading: loading } = useTransactions({
|
|
status: statusFilter || undefined,
|
|
limit,
|
|
offset: page * limit,
|
|
});
|
|
|
|
const totalPages = transactions ? Math.ceil(transactions.total / limit) : 0;
|
|
|
|
// Summary stats
|
|
const completedTotal =
|
|
transactions?.items
|
|
.filter((t) => t.status === 'COMPLETED')
|
|
.reduce((sum, t) => sum + Number(t.amountVND), 0) ?? 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Thanh toán</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Lịch sử giao dịch và quản lý thanh toán
|
|
</p>
|
|
</div>
|
|
|
|
{/* Summary cards */}
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Tổng giao dịch</CardDescription>
|
|
<CardTitle className="text-2xl">
|
|
{loading ? '...' : (transactions?.total ?? 0)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Đã thanh toán</CardDescription>
|
|
<CardTitle className="text-2xl">
|
|
{loading ? '...' : formatVND(completedTotal)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Đang chờ</CardDescription>
|
|
<CardTitle className="text-2xl">
|
|
{loading
|
|
? '...'
|
|
: (transactions?.items.filter((t) => t.status === 'PENDING' || t.status === 'PROCESSING').length ?? 0)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Transactions table */}
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg">Lịch sử giao dịch</CardTitle>
|
|
<CardDescription>Tất cả giao dịch thanh toán của bạn</CardDescription>
|
|
</div>
|
|
<div className="w-40">
|
|
<Select
|
|
value={statusFilter}
|
|
onChange={(e) => {
|
|
setStatusFilter(e.target.value);
|
|
setPage(0);
|
|
}}
|
|
>
|
|
<option value="">Tất cả</option>
|
|
<option value="PENDING">Chờ xử lý</option>
|
|
<option value="PROCESSING">Đang xử lý</option>
|
|
<option value="COMPLETED">Thành công</option>
|
|
<option value="FAILED">Thất bại</option>
|
|
<option value="REFUNDED">Hoàn tiền</option>
|
|
</Select>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
|
Đang tải...
|
|
</div>
|
|
) : !transactions || transactions.items.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
|
Chưa có giao dịch nào
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Desktop table */}
|
|
<div className="hidden sm:block">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Ngày</TableHead>
|
|
<TableHead>Loại</TableHead>
|
|
<TableHead>Nhà cung cấp</TableHead>
|
|
<TableHead className="text-right">Số tiền</TableHead>
|
|
<TableHead>Trạng thái</TableHead>
|
|
<TableHead>Mã GD</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{transactions.items.map((tx) => {
|
|
const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const };
|
|
return (
|
|
<TableRow key={tx.id}>
|
|
<TableCell className="text-sm">
|
|
{new Date(tx.createdAt).toLocaleDateString('vi-VN')}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{TYPE_LABELS[tx.type] ?? tx.type}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{PROVIDER_LABELS[tx.provider] ?? tx.provider}
|
|
</TableCell>
|
|
<TableCell className="text-right font-semibold">
|
|
{formatVND(tx.amountVND)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{tx.providerTxId ? tx.providerTxId.slice(0, 12) + '...' : '—'}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Mobile cards */}
|
|
<div className="space-y-3 sm:hidden">
|
|
{transactions.items.map((tx) => {
|
|
const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const };
|
|
return (
|
|
<div key={tx.id} className="rounded-lg border p-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">
|
|
{TYPE_LABELS[tx.type] ?? tx.type}
|
|
</span>
|
|
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
|
</div>
|
|
<div className="mt-2 flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
{new Date(tx.createdAt).toLocaleDateString('vi-VN')} —{' '}
|
|
{PROVIDER_LABELS[tx.provider] ?? tx.provider}
|
|
</span>
|
|
<span className="font-semibold">{formatVND(tx.amountVND)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
Trang {page + 1}/{totalPages} ({transactions.total} giao dịch)
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page === 0}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
>
|
|
Trước
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={page + 1 >= totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
Sau
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|