Files
rentall-app/frontend/src/pages/EarningsDashboard.tsx
2026-01-19 22:50:53 -05:00

338 lines
12 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { Link } from "react-router";
import { rentalAPI, userAPI, stripeAPI } from "../services/api";
import { Rental, User } from "../types";
import StripeConnectOnboarding from "../components/StripeConnectOnboarding";
import EarningsStatus from "../components/EarningsStatus";
interface EarningsData {
totalEarnings: number;
pendingEarnings: number;
completedEarnings: number;
rentalsWithEarnings: Rental[];
}
interface AccountStatus {
detailsSubmitted: boolean;
payoutsEnabled: boolean;
}
const EarningsDashboard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [earningsData, setEarningsData] = useState<EarningsData | null>(null);
const [userProfile, setUserProfile] = useState<User | null>(null);
const [accountStatus, setAccountStatus] = useState<AccountStatus | null>(
null
);
const [showOnboarding, setShowOnboarding] = useState(false);
useEffect(() => {
fetchUserProfile();
fetchEarningsData();
}, []);
const fetchUserProfile = async () => {
try {
const response = await userAPI.getProfile();
setUserProfile(response.data);
// If user has a Stripe account, fetch account status
if (response.data.stripeConnectedAccountId) {
await fetchAccountStatus();
}
} catch (err) {
console.error("Failed to fetch user profile:", err);
}
};
const fetchAccountStatus = async () => {
try {
const response = await stripeAPI.getAccountStatus();
setAccountStatus({
detailsSubmitted: response.data.detailsSubmitted,
payoutsEnabled: response.data.payoutsEnabled,
});
} catch (err) {
console.error("Failed to fetch account status:", err);
}
};
const fetchEarningsData = async () => {
try {
// Get completed rentals where user is the owner
const response = await rentalAPI.getListings();
const rentals = response.data || [];
// Filter for completed rentals with earnings data
const completedRentals = rentals.filter(
(rental: Rental) => rental.status === "completed" && rental.payoutAmount
);
// Calculate earnings - convert string values to numbers
const totalEarnings = completedRentals.reduce(
(sum: number, rental: Rental) =>
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
0
);
const pendingEarnings = completedRentals
.filter((rental: Rental) => rental.bankDepositStatus !== "paid")
.reduce(
(sum: number, rental: Rental) =>
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
0
);
const completedEarnings = completedRentals
.filter((rental: Rental) => rental.bankDepositStatus === "paid")
.reduce(
(sum: number, rental: Rental) =>
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
0
);
setEarningsData({
totalEarnings,
pendingEarnings,
completedEarnings,
rentalsWithEarnings: completedRentals,
});
} catch (err: any) {
setError(err.response?.data?.message || "Failed to fetch earnings data");
} finally {
setLoading(false);
}
};
const handleSetupComplete = () => {
setShowOnboarding(false);
fetchUserProfile(); // Refresh user profile after setup
fetchEarningsData();
};
if (loading) {
return (
<div className="container mt-5">
<div className="text-center">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
const hasStripeAccount = !!userProfile?.stripeConnectedAccountId;
const isOnboardingComplete = accountStatus?.detailsSubmitted ?? false;
const payoutsEnabled = accountStatus?.payoutsEnabled ?? true;
// Don't show setup card until we have account status (if user has a Stripe account)
// This prevents the setup card from flashing briefly while fetching account status
const accountStatusLoading = hasStripeAccount && accountStatus === null;
// Show setup card if: no account, onboarding incomplete, or payouts disabled
const showSetupCard =
!accountStatusLoading &&
(!hasStripeAccount || !isOnboardingComplete || !payoutsEnabled);
return (
<div className="container mt-4">
<div className="row">
<div className="col-12">
<h1>Earnings</h1>
<p className="text-muted">
Manage your rental earnings and payment setup.{" "}
<Link to="/faq" target="_blank">
Calculate what you can earn here
</Link>{" "}
or <Link to="/faq#earnings">learn how payouts work</Link>.
</p>
</div>
</div>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
{/* Earnings Setup - show if not fully set up or payouts disabled */}
{showSetupCard && (
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">Earnings Setup</h5>
</div>
<div className="card-body">
<EarningsStatus
hasStripeAccount={hasStripeAccount}
isOnboardingComplete={isOnboardingComplete}
payoutsEnabled={payoutsEnabled}
onSetupClick={() => setShowOnboarding(true)}
/>
</div>
</div>
)}
<div className="row">
<div className="col-12">
{/* Earnings Overview */}
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">Earnings Overview</h5>
</div>
<div className="card-body">
<div className="row text-center">
<div className="col-md-4">
<h3 className="text-primary">
${(earningsData?.totalEarnings || 0).toFixed(2)}
</h3>
<p className="text-muted">Total Earnings</p>
</div>
<div className="col-md-4">
<h3 className="text-warning">
${(earningsData?.pendingEarnings || 0).toFixed(2)}
</h3>
<p className="text-muted">Pending Earnings</p>
</div>
<div className="col-md-4">
<h3 className="text-success">
${(earningsData?.completedEarnings || 0).toFixed(2)}
</h3>
<p className="text-muted">Paid Out</p>
</div>
</div>
</div>
</div>
{/* Earnings History */}
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">Earnings History</h5>
</div>
<div className="card-body">
{earningsData?.rentalsWithEarnings.length === 0 ? (
<p className="text-muted text-center">
No completed rentals yet
</p>
) : (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Item</th>
<th>Rental Period</th>
<th>Total Amount</th>
<th>Your Earnings</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{earningsData?.rentalsWithEarnings.map((rental) => (
<tr key={rental.id}>
<td>
<Link to={`/items/${rental.itemId}`}>
{rental.item?.name || "Item"}
</Link>
</td>
<td>
<small>
{new Date(rental.startDateTime).toLocaleString()}
<br />
to
<br />
{new Date(rental.endDateTime).toLocaleString()}
</small>
</td>
<td>
$
{parseFloat(
rental.totalAmount?.toString() || "0"
).toFixed(2)}
</td>
<td className="text-success">
<strong>
$
{parseFloat(
rental.payoutAmount?.toString() || "0"
).toFixed(2)}
</strong>
</td>
<td>
{(() => {
// Determine badge based on bank deposit and payout status
let badgeClass = "bg-secondary";
let badgeLabel = "Pending";
let badgeTooltip =
"Waiting for rental to complete or Stripe setup.";
if (rental.bankDepositStatus === "paid") {
badgeClass = "bg-success";
badgeLabel = "Deposited";
badgeTooltip = rental.bankDepositAt
? `Deposited to your bank on ${new Date(
rental.bankDepositAt
).toLocaleDateString()}`
: "Funds deposited to your bank account.";
} else if (
rental.bankDepositStatus === "failed"
) {
badgeClass = "bg-danger";
badgeLabel = "Deposit Failed";
badgeTooltip =
"Bank deposit failed. Please check your Stripe dashboard.";
} else if (
rental.bankDepositStatus === "in_transit"
) {
badgeClass = "bg-info";
badgeLabel = "In Transit to Bank";
badgeTooltip =
"Funds are on their way to your bank.";
} else if (rental.payoutStatus === "completed") {
badgeClass = "bg-info";
badgeLabel = "Transferred to Stripe";
badgeTooltip =
"In your Stripe balance. Bank deposit in 2-7 business days.";
} else if (rental.payoutStatus === "failed") {
badgeClass = "bg-danger";
badgeLabel = "Transfer Failed";
badgeTooltip =
"Transfer failed. We'll retry automatically.";
}
return (
<span
className={`badge ${badgeClass}`}
title={badgeTooltip}
style={{ cursor: "help" }}
>
{badgeLabel}
</span>
);
})()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
{/* Stripe Connect Onboarding Modal */}
{showOnboarding && (
<StripeConnectOnboarding
onComplete={handleSetupComplete}
onCancel={() => setShowOnboarding(false)}
hasExistingAccount={hasStripeAccount}
/>
)}
</div>
);
};
export default EarningsDashboard;