veza/apps/web/src/components/admin/AdminTransfersView.tsx

250 lines
8.5 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useCallback } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Select } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { RefreshCcw, ChevronLeft, ChevronRight } from 'lucide-react';
import { commerceService, type SellerTransfer } from '@/services/commerceService';
import { useToast } from '@/components/feedback/ToastProvider';
const LIMIT = 20;
function statusBadgeClass(status: string): string {
switch (status) {
case 'completed':
return 'bg-success/20 text-success';
case 'failed':
return 'bg-destructive/20 text-destructive';
case 'permanently_failed':
return 'bg-muted text-muted-foreground';
case 'pending':
return 'bg-warning/20 text-warning';
case 'skipped':
return 'bg-primary/20 text-primary';
default:
return 'bg-muted text-muted-foreground';
}
}
function truncateId(id: string): string {
if (!id || id.length < 12) return id;
return `${id.slice(0, 8)}`;
}
export function AdminTransfersView() {
const { addToast } = useToast();
const [transfers, setTransfers] = useState<SellerTransfer[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sellerIdFilter, setSellerIdFilter] = useState('');
const [offset, setOffset] = useState(0);
const [retrying, setRetrying] = useState<string | null>(null);
useEffect(() => {
setOffset(0);
}, [statusFilter, sellerIdFilter]);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params: Record<string, string | number | undefined> = {
limit: LIMIT,
offset,
};
if (statusFilter && statusFilter !== 'all') params.status = statusFilter;
if (sellerIdFilter.trim()) params.seller_id = sellerIdFilter.trim();
const res = await commerceService.getAdminTransfers(params);
setTransfers(res.transfers);
setTotal(res.total);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
setTransfers([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [offset, statusFilter, sellerIdFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleRetry = async (transferId: string) => {
setRetrying(transferId);
try {
await commerceService.retryAdminTransfer(transferId);
addToast({ message: 'Transfer retried successfully', type: 'success' });
fetchData();
} catch (e) {
addToast({
message: e instanceof Error ? e.message : 'Retry failed',
type: 'error',
});
} finally {
setRetrying(null);
}
};
const hasNext = offset + LIMIT < total;
const hasPrev = offset > 0;
if (error) {
return (
<div className="container mx-auto px-4 py-8 max-w-layout-content">
<ErrorDisplay
error={error}
onRetry={fetchData}
title="Failed to load transfers"
context={{ action: 'loading', resource: 'admin transfers' }}
variant="card"
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-layout-content space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold text-foreground">Platform Transfers</h1>
<div className="flex flex-wrap items-center gap-2">
<Select
options={[
{ value: 'all', label: 'All statuses' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'permanently_failed', label: 'Permanent fail' },
{ value: 'pending', label: 'Pending' },
{ value: 'skipped', label: 'Skipped' },
]}
value={statusFilter}
onChange={(v) => setStatusFilter(Array.isArray(v) ? v[0] ?? 'all' : v ?? 'all')}
placeholder="Status"
className="w-40"
/>
<Input
placeholder="Seller ID"
value={sellerIdFilter}
onChange={(e) => setSellerIdFilter(e.target.value)}
className="w-48"
/>
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
<RefreshCcw className="w-4 h-4 mr-1" />
Refresh
</Button>
</div>
</div>
<Card variant="default" className="overflow-hidden">
{loading ? (
<div className="p-6 space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : transfers.length === 0 ? (
<div className="p-12 text-center text-muted-foreground">
No transfers found
</div>
) : (
<>
<Table aria-label="Platform transfers">
<TableHeader>
<TableRow>
<TableHead>Seller</TableHead>
<TableHead>Order</TableHead>
<TableHead>Net (EUR)</TableHead>
<TableHead>Fee (EUR)</TableHead>
<TableHead>Status</TableHead>
<TableHead>Retries</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transfers.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-mono text-xs">
{truncateId(t.seller_id ?? t.id)}
</TableCell>
<TableCell className="font-mono text-xs">
{truncateId(t.order_id)}
</TableCell>
<TableCell>
{(t.amount_cents / 100).toFixed(2)}
</TableCell>
<TableCell>
{(t.platform_fee_cents / 100).toFixed(2)}
</TableCell>
<TableCell>
<span
className={`text-xs px-2 py-0.5 rounded ${statusBadgeClass(
t.status
)}`}
>
{t.status}
</span>
</TableCell>
<TableCell>{t.retry_count ?? 0}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{new Date(t.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
{t.status === 'failed' && (
<Button
variant="outline"
size="sm"
onClick={() => handleRetry(t.id)}
disabled={retrying === t.id}
>
{retrying === t.id ? '…' : 'Retry'}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-sm text-muted-foreground">
{offset + 1}{Math.min(offset + LIMIT, total)} of {total}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setOffset((o) => Math.max(0, o - LIMIT))}
disabled={!hasPrev}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setOffset((o) => o + LIMIT)}
disabled={!hasNext}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</Card>
</div>
);
}