fix: stabilize frontend — 98 TS errors to 0, align API endpoints, optimize bundle
- Fix 98 TypeScript errors across 37 files:
- Service layer double-unwrapping (subscriptionService, distributionService, gearService)
- Self-referencing variables in SearchPageResults
- FeedView/ExploreView .posts→.items alignment
- useQueueSync Zustand subscribe API
- AdminAuditLogsView missing interface fields
- Toast proxy type, interceptor type narrowing
- 22 unused imports/variables removed
- 5 storybook mock data fixes
- Align frontend API calls with backend endpoints:
- Analytics: useAnalyticsView now calls /creator/analytics/dashboard (was /analytics)
- Chat: chatService uses /conversations (was mock data), WS URL from backend token
- Dashboard StatsSection: uses real /dashboard API data (was hardcoded zeros)
- Settings: suppress 2FA toast error when endpoint unavailable
- Fix marketplace products: seed uses 'active' status (was 'published')
- Enrich seed: admin follows all creators (feed has content)
- Optimize bundle: vendor catch-all 793KB→318KB gzip (-60%)
Split into vendor-charts, vendor-emoji, vendor-swagger, vendor-media, etc.
- Clean repo: remove ~100 orphaned screenshots, audit reports, logs from root
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:18:49 +00:00
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
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 22:42:02 +00:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|