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>
161 lines
5.7 KiB
TypeScript
161 lines
5.7 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { DollarSign, ExternalLink } from 'lucide-react';
|
|
import { DataTable } from '../../components/ui/DataTable';
|
|
import { Badge } from '../../components/ui/Badge';
|
|
import { GradientButton } from '../../components/ui/GradientButton';
|
|
import { StatCard } from '../../components/ui/StatCard';
|
|
import { api } from '../../api/client';
|
|
import type { Payout } from '../../types/rental';
|
|
|
|
interface AccountStatus {
|
|
connected: boolean;
|
|
chargesEnabled: boolean;
|
|
payoutsEnabled: boolean;
|
|
onboardingUrl?: string;
|
|
}
|
|
|
|
export function LandlordPayoutsPage() {
|
|
const [payouts, setPayouts] = useState<Payout[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [accountStatus, setAccountStatus] = useState<AccountStatus | null>(null);
|
|
const [connectLoading, setConnectLoading] = useState(false);
|
|
|
|
const fetchPayouts = useCallback(async () => {
|
|
try {
|
|
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
|
const res = await api.get<Payout[]>(`/payouts?${params}`);
|
|
setPayouts(res);
|
|
setTotal(res.length);
|
|
} catch {
|
|
// silently fail
|
|
}
|
|
}, [page]);
|
|
|
|
useEffect(() => {
|
|
fetchPayouts();
|
|
api.get<AccountStatus>('/payouts/account-status').then(setAccountStatus).catch(() => {});
|
|
}, [fetchPayouts]);
|
|
|
|
const handleSetupStripe = async () => {
|
|
setConnectLoading(true);
|
|
try {
|
|
const { url } = await api.post<{ url: string }>('/payouts/setup-account');
|
|
window.location.href = url;
|
|
} catch {
|
|
setConnectLoading(false);
|
|
}
|
|
};
|
|
|
|
const statusBadge = (status: string) => {
|
|
const map: Record<string, 'success' | 'warning' | 'info' | 'error' | 'default'> = {
|
|
COMPLETED: 'success',
|
|
PROCESSING: 'info',
|
|
PENDING: 'warning',
|
|
FAILED: 'error',
|
|
};
|
|
return <Badge variant={map[status] || 'default'} size="sm">{status}</Badge>;
|
|
};
|
|
|
|
const totalEarned = payouts.filter((p) => p.status === 'COMPLETED').reduce((sum, p) => sum + p.netAmount, 0);
|
|
const pendingAmount = payouts.filter((p) => p.status === 'PENDING' || p.status === 'PROCESSING').reduce((sum, p) => sum + p.netAmount, 0);
|
|
|
|
return (
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">Payouts</h1>
|
|
|
|
{/* Stripe Connect status */}
|
|
{accountStatus && !accountStatus.payoutsEnabled && (
|
|
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-5 mb-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h3 className="font-semibold text-amber-900 mb-1">
|
|
{accountStatus.connected ? 'Complete Stripe Setup' : 'Set Up Payouts'}
|
|
</h3>
|
|
<p className="text-sm text-amber-700">
|
|
{accountStatus.connected
|
|
? 'Your Stripe account needs additional information before payouts can be processed.'
|
|
: 'Connect your Stripe account to receive payouts for your rental bookings.'}
|
|
</p>
|
|
</div>
|
|
<GradientButton size="sm" onClick={handleSetupStripe} isLoading={connectLoading}>
|
|
<ExternalLink className="w-4 h-4 mr-1.5" />
|
|
{accountStatus.connected ? 'Complete Setup' : 'Connect Stripe'}
|
|
</GradientButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
|
<StatCard icon={DollarSign} label="Total Earned" value={`$${totalEarned.toFixed(2)}`} color="green" />
|
|
<StatCard icon={DollarSign} label="Pending" value={`$${pendingAmount.toFixed(2)}`} color="yellow" />
|
|
</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 || 'Unknown Listing'}
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
{p.booking?.tenant?.fullName || 'Unknown Tenant'}
|
|
</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'dates',
|
|
header: 'Period',
|
|
render: (p: Payout) => (
|
|
<div className="text-sm">
|
|
{p.booking ? (
|
|
<>
|
|
<div>{new Date(p.booking.startDate).toLocaleDateString()}</div>
|
|
<div className="text-gray-400">to {new Date(p.booking.endDate).toLocaleDateString()}</div>
|
|
</>
|
|
) : (
|
|
<span className="text-gray-400">--</span>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'gross',
|
|
header: 'Gross',
|
|
render: (p: Payout) => <span className="text-sm">${p.grossAmount.toFixed(2)}</span>,
|
|
},
|
|
{
|
|
key: 'commission',
|
|
header: 'Commission',
|
|
render: (p: Payout) => <span className="text-sm text-gray-500">-${p.commissionAmount.toFixed(2)}</span>,
|
|
},
|
|
{
|
|
key: 'net',
|
|
header: 'Net',
|
|
render: (p: Payout) => <span className="font-medium text-green-700">${p.netAmount.toFixed(2)}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Status',
|
|
render: (p: Payout) => statusBadge(p.status),
|
|
},
|
|
{
|
|
key: 'date',
|
|
header: 'Date',
|
|
render: (p: Payout) => new Date(p.createdAt).toLocaleDateString(),
|
|
},
|
|
]}
|
|
data={payouts}
|
|
total={total}
|
|
page={page}
|
|
pageSize={20}
|
|
onPageChange={setPage}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|