Add rental system: listings, bookings, payments, payouts, reviews
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>
This commit is contained in:
154
client/src/pages/admin/AdminRentalPayoutsPage.tsx
Normal file
154
client/src/pages/admin/AdminRentalPayoutsPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user