Full rental marketplace with 6 categories (apartment, house, car, motorcycle, bicycle, ebike). Booking workflow: create → confirm → pay → active → complete → payout. Landlord dashboard, admin moderation, availability calendar, Stripe Connect payouts. 14 QA bugs found and fixed including validator schemas, API response types, HTTP methods. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { DollarSign, Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
|
import { DataTable } from '../../components/ui/DataTable';
|
|
import { Badge } from '../../components/ui/Badge';
|
|
import { Button } from '../../components/ui/Button';
|
|
import { StatCard } from '../../components/ui/StatCard';
|
|
import { api } from '../../api/client';
|
|
import { formatCurrency } from '../../utils/format';
|
|
import type { Payout, PayoutStatus } from '../../types/rental';
|
|
|
|
const STATUS_TABS: (PayoutStatus | 'ALL')[] = ['ALL', 'PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'];
|
|
|
|
interface PayoutStats {
|
|
totalPending: number;
|
|
totalProcessing: number;
|
|
totalCompleted: number;
|
|
totalFailed: number;
|
|
pendingAmount: number;
|
|
completedAmount: number;
|
|
}
|
|
|
|
export function AdminRentalPayoutsPage() {
|
|
const [payouts, setPayouts] = useState<Payout[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [search, setSearch] = useState('');
|
|
const [tab, setTab] = useState<PayoutStatus | 'ALL'>('ALL');
|
|
const [stats] = useState<PayoutStats | null>(null);
|
|
|
|
const fetchPayouts = useCallback(async () => {
|
|
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
|
if (search) params.set('search', search);
|
|
if (tab !== 'ALL') params.set('status', tab);
|
|
|
|
try {
|
|
const res = await api.get<{ data: Payout[]; total: number }>(`/admin/rental-payouts?${params}`);
|
|
setPayouts(res.data);
|
|
setTotal(res.total);
|
|
} catch {
|
|
setPayouts([]);
|
|
}
|
|
}, [page, search, tab]);
|
|
|
|
useEffect(() => {
|
|
fetchPayouts();
|
|
}, [fetchPayouts]);
|
|
|
|
const handleRetry = async (payoutId: string) => {
|
|
try {
|
|
await api.patch(`/admin/rental-payouts/${payoutId}/retry`);
|
|
fetchPayouts();
|
|
} catch {}
|
|
};
|
|
|
|
const statusBadge = (status: PayoutStatus) => {
|
|
const v = status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'error' : status === 'PROCESSING' ? 'info' : 'warning';
|
|
return <Badge variant={v} size="sm">{status}</Badge>;
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Rental Payouts</h1>
|
|
|
|
{/* Stats */}
|
|
{stats && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
<StatCard icon={Clock} label="Pending Payouts" value={stats.totalPending} color="yellow" />
|
|
<StatCard icon={DollarSign} label="Pending Amount" value={formatCurrency(stats.pendingAmount)} color="blue" />
|
|
<StatCard icon={CheckCircle} label="Completed" value={formatCurrency(stats.completedAmount)} color="green" />
|
|
<StatCard icon={AlertCircle} label="Failed" value={stats.totalFailed} color="pink" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Status filter tabs */}
|
|
<div className="flex gap-2 mb-4 flex-wrap">
|
|
{STATUS_TABS.map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => { setTab(t); setPage(1); }}
|
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
|
tab === t ? 'bg-primary-100 text-primary-700' : 'text-gray-500 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={[
|
|
{
|
|
key: 'booking',
|
|
header: 'Booking',
|
|
render: (p: Payout) => (
|
|
<div>
|
|
<p className="font-medium text-gray-900 truncate max-w-xs">{p.booking?.rentalListing?.title ?? 'N/A'}</p>
|
|
<p className="text-xs text-gray-400">
|
|
{p.booking ? `${new Date(p.booking.startDate).toLocaleDateString()} - ${new Date(p.booking.endDate).toLocaleDateString()}` : ''}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'tenant',
|
|
header: 'Tenant',
|
|
render: (p: Payout) => p.booking?.tenant?.fullName ?? 'N/A',
|
|
},
|
|
{
|
|
key: 'gross',
|
|
header: 'Gross',
|
|
render: (p: Payout) => formatCurrency(p.grossAmount),
|
|
},
|
|
{
|
|
key: 'commission',
|
|
header: 'Commission',
|
|
render: (p: Payout) => formatCurrency(p.commissionAmount),
|
|
},
|
|
{
|
|
key: 'net',
|
|
header: 'Net Payout',
|
|
render: (p: Payout) => <span className="font-medium text-primary-600">{formatCurrency(p.netAmount)}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
render: (p: Payout) => statusBadge(p.status),
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
header: 'Date',
|
|
render: (p: Payout) => new Date(p.createdAt).toLocaleDateString(),
|
|
},
|
|
]}
|
|
data={payouts}
|
|
total={total}
|
|
page={page}
|
|
pageSize={20}
|
|
onPageChange={setPage}
|
|
searchValue={search}
|
|
onSearch={(v) => { setSearch(v); setPage(1); }}
|
|
searchPlaceholder="Search payouts..."
|
|
actions={(p: Payout) => (
|
|
<div>
|
|
{p.status === 'FAILED' && (
|
|
<Button variant="secondary" size="sm" onClick={() => handleRetry(p.id)}>
|
|
Retry
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|