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:
160
client/src/pages/landlord/LandlordPayoutsPage.tsx
Normal file
160
client/src/pages/landlord/LandlordPayoutsPage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user