veza/apps/web/src/components/admin/AdminTransfersView.tsx
senke 022770ef9f feat(v0.701): AdminTransfers page/route, MSW, stories, Deep Health, API ref, docs, scope v0.702
- Step 13: AdminTransfersPage, LazyAdminTransfers, route /admin/transfers
- Step 14: MSW handlers admin transfers
- Step 15: AdminTransfersView stories (Default, Empty, WithFailedTransfers, Error, Loading)
- Step 16-17: DeepHealth handler (disk, config), GET /health/deep
- Step 19: health_deep_test.go (4 tests)
- Step 20: docs/API_REFERENCE.md
- Step 21: Archive V0_604, MIGRATIONS.md migration 116
- Step 22: CHANGELOG, PROJECT_STATE, FEATURE_STATUS v0.701
- Step 23: RETROSPECTIVE_V0701, V0_702 placeholder, SCOPE_CONTROL, .cursorrules
- Step 24: Archive V0_701_RELEASE_SCOPE
- Fix: AdminTransfersView Select component (use options API)
2026-02-23 23:42:02 +01:00

249 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { 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>
);
}