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:
delta-lynx-89e8
2026-02-22 15:33:29 -08:00
parent 8961fa701a
commit dbbbbd26f4
90 changed files with 11052 additions and 124 deletions

View 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>
);
}