Files
goodgo-platform/apps/web/app/(dashboard)/dashboard/payments/page.tsx
Ho Ngoc Hai 9d120dd21f feat(web): add React Query, dark mode toggle, and error retry UX
- 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>
2026-04-08 23:02:44 +07:00

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 quản 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ử </option>
<option value="PROCESSING">Đang xử </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 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> 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>
);
}