Files
smoothschedule/legacy_reference/frontend/src/pages/Payments.tsx
poduck 2e111364a2 Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 01:43:20 -05:00

871 lines
42 KiB
TypeScript

import React, { useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
CreditCard,
Plus,
Trash2,
Star,
X,
TrendingUp,
DollarSign,
ArrowUpRight,
ArrowDownRight,
Download,
Filter,
Calendar,
Wallet,
BarChart3,
RefreshCcw,
FileSpreadsheet,
FileText,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
CheckCircle,
Clock,
XCircle,
ExternalLink,
Eye,
} from 'lucide-react';
import { User, Business, PaymentMethod, Customer } from '../types';
import { CUSTOMERS } from '../mockData';
import PaymentSettingsSection from '../components/PaymentSettingsSection';
import TransactionDetailModal from '../components/TransactionDetailModal';
import Portal from '../components/Portal';
import {
useTransactions,
useTransactionSummary,
useStripeBalance,
useStripePayouts,
useStripeCharges,
useExportTransactions,
} from '../hooks/useTransactionAnalytics';
import { usePaymentConfig } from '../hooks/usePayments';
import { TransactionFilters } from '../api/payments';
type TabType = 'overview' | 'transactions' | 'payouts' | 'settings';
const Payments: React.FC = () => {
const { user: effectiveUser, business } = useOutletContext<{ user: User, business: Business }>();
const isBusiness = effectiveUser.role === 'owner' || effectiveUser.role === 'manager';
const isCustomer = effectiveUser.role === 'customer';
// Tab state
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Filter state
const [filters, setFilters] = useState<TransactionFilters>({
status: 'all',
transaction_type: 'all',
page: 1,
page_size: 20,
});
const [showFilters, setShowFilters] = useState(false);
const [dateRange, setDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' });
// Export modal state
const [showExportModal, setShowExportModal] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'xlsx' | 'pdf' | 'quickbooks'>('csv');
// Transaction detail modal state
const [selectedTransactionId, setSelectedTransactionId] = useState<number | null>(null);
// Data hooks
const { data: paymentConfig } = usePaymentConfig();
const canAcceptPayments = paymentConfig?.can_accept_payments || false;
const activeFilters: TransactionFilters = {
...filters,
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
};
const { data: transactions, isLoading: transactionsLoading, refetch: refetchTransactions } = useTransactions(activeFilters);
const { data: summary, isLoading: summaryLoading } = useTransactionSummary({
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
});
const { data: balance, isLoading: balanceLoading } = useStripeBalance();
const { data: payoutsData, isLoading: payoutsLoading } = useStripePayouts(20);
const { data: chargesData } = useStripeCharges(10);
const exportMutation = useExportTransactions();
// Customer view state (for customer-facing)
const [customerProfile, setCustomerProfile] = useState<Customer | undefined>(
CUSTOMERS.find(c => c.userId === effectiveUser.id)
);
const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false);
// Customer handlers
const handleSetDefault = (pmId: string) => {
if (!customerProfile) return;
const updatedMethods = customerProfile.paymentMethods.map(pm => ({
...pm,
isDefault: pm.id === pmId
}));
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
};
const handleDeleteMethod = (pmId: string) => {
if (!customerProfile) return;
if (window.confirm("Are you sure you want to delete this payment method?")) {
const updatedMethods = customerProfile.paymentMethods.filter(pm => pm.id !== pmId);
if (updatedMethods.length > 0 && !updatedMethods.some(pm => pm.isDefault)) {
updatedMethods[0].isDefault = true;
}
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
}
};
const handleAddCard = (e: React.FormEvent) => {
e.preventDefault();
if (!customerProfile) return;
const newCard: PaymentMethod = {
id: `pm_${Date.now()}`,
brand: 'Visa',
last4: String(Math.floor(1000 + Math.random() * 9000)),
isDefault: customerProfile.paymentMethods.length === 0
};
const updatedMethods = [...customerProfile.paymentMethods, newCard];
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
setIsAddCardModalOpen(false);
};
// Export handler
const handleExport = () => {
exportMutation.mutate({
format: exportFormat,
start_date: dateRange.start || undefined,
end_date: dateRange.end || undefined,
});
setShowExportModal(false);
};
// Status badge helper
const getStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
succeeded: { bg: 'bg-green-100', text: 'text-green-800', icon: <CheckCircle size={12} /> },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: <Clock size={12} /> },
failed: { bg: 'bg-red-100', text: 'text-red-800', icon: <XCircle size={12} /> },
refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: <RefreshCcw size={12} /> },
partially_refunded: { bg: 'bg-orange-100', text: 'text-orange-800', icon: <RefreshCcw size={12} /> },
};
const style = styles[status] || styles.pending;
const displayStatus = status.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap ${style.bg} ${style.text}`}>
{style.icon}
{displayStatus}
</span>
);
};
// Format date helper
const formatDate = (dateStr: string | number) => {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
// Format time helper
const formatDateTime = (dateStr: string | number) => {
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Business Owner/Manager View
if (isBusiness) {
return (
<div className="p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Payments & Analytics</h2>
<p className="text-gray-500 dark:text-gray-400">Manage payments and view transaction analytics</p>
</div>
{canAcceptPayments && (
<button
onClick={() => setShowExportModal(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
<Download size={16} />
Export Data
</button>
)}
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'transactions', label: 'Transactions', icon: CreditCard },
{ id: 'payouts', label: 'Payouts', icon: Wallet },
{ id: 'settings', label: 'Settings', icon: CreditCard },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id
? 'border-brand-500 text-brand-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<tab.icon size={18} />
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="space-y-6">
{!canAcceptPayments ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<div className="flex items-start gap-3">
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={24} />
<div>
<h3 className="font-semibold text-yellow-800">Payment Setup Required</h3>
<p className="text-yellow-700 mt-1">
Complete your payment setup in the Settings tab to start accepting payments and see analytics.
</p>
<button
onClick={() => setActiveTab('settings')}
className="mt-3 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-lg hover:bg-yellow-200"
>
Go to Settings
</button>
</div>
</div>
</div>
) : (
<>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Revenue */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Total Revenue</p>
<div className="p-2 bg-green-100 rounded-lg">
<DollarSign className="text-green-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.net_revenue_display || '$0.00'}
</p>
<p className="text-sm text-gray-500 mt-1">
{summary?.total_transactions || 0} transactions
</p>
</>
)}
</div>
{/* Available Balance */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Available Balance</p>
<div className="p-2 bg-blue-100 rounded-lg">
<Wallet className="text-blue-600" size={20} />
</div>
</div>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
${((balance?.available_total || 0) / 100).toFixed(2)}
</p>
<p className="text-sm text-gray-500 mt-1">
${((balance?.pending_total || 0) / 100).toFixed(2)} pending
</p>
</>
)}
</div>
{/* Success Rate */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Success Rate</p>
<div className="p-2 bg-purple-100 rounded-lg">
<TrendingUp className="text-purple-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.total_transactions
? ((summary.successful_transactions / summary.total_transactions) * 100).toFixed(1)
: '0'}%
</p>
<p className="text-sm text-green-600 mt-1 flex items-center gap-1">
<ArrowUpRight size={14} />
{summary?.successful_transactions || 0} successful
</p>
</>
)}
</div>
{/* Average Transaction */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Avg Transaction</p>
<div className="p-2 bg-orange-100 rounded-lg">
<BarChart3 className="text-orange-600" size={20} />
</div>
</div>
{summaryLoading ? (
<Loader2 className="animate-spin text-gray-400 mt-2" size={24} />
) : (
<>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-2">
{summary?.average_transaction_display || '$0.00'}
</p>
<p className="text-sm text-gray-500 mt-1">
Platform fees: {summary?.total_fees_display || '$0.00'}
</p>
</>
)}
</div>
</div>
{/* Recent Transactions */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Recent Transactions</h3>
<button
onClick={() => setActiveTab('transactions')}
className="text-sm text-brand-600 hover:text-brand-700 font-medium"
>
View All
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{transactionsLoading ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : transactions?.results?.length ? (
transactions.results.slice(0, 5).map((txn) => (
<tr
key={txn.id}
onClick={() => setSelectedTransactionId(txn.id)}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">
{txn.customer_name || 'Unknown'}
</p>
<p className="text-sm text-gray-500">{txn.customer_email}</p>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateTime(txn.created_at)}
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{txn.amount_display}</p>
<p className="text-xs text-gray-500">Fee: {txn.fee_display}</p>
</td>
<td className="px-6 py-4">
{getStatusBadge(txn.status)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
No transactions yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
)}
{activeTab === 'transactions' && (
<div className="space-y-4">
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Calendar size={16} className="text-gray-400" />
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Start date"
/>
<span className="text-gray-400">to</span>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="End date"
/>
</div>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value as any, page: 1 })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Statuses</option>
<option value="succeeded">Succeeded</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="refunded">Refunded</option>
</select>
<select
value={filters.transaction_type}
onChange={(e) => setFilters({ ...filters, transaction_type: e.target.value as any, page: 1 })}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Types</option>
<option value="payment">Payment</option>
<option value="refund">Refund</option>
</select>
<button
onClick={() => refetchTransactions()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
>
<RefreshCcw size={14} />
Refresh
</button>
</div>
</div>
{/* Transactions Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Transaction</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Net</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{transactionsLoading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : transactions?.results?.length ? (
transactions.results.map((txn) => (
<tr
key={txn.id}
onClick={() => setSelectedTransactionId(txn.id)}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
>
<td className="px-6 py-4">
<p className="text-sm font-mono text-gray-600">{txn.stripe_payment_intent_id.slice(0, 18)}...</p>
<p className="text-xs text-gray-400 capitalize">{txn.transaction_type}</p>
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">
{txn.customer_name || 'Unknown'}
</p>
<p className="text-sm text-gray-500">{txn.customer_email}</p>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateTime(txn.created_at)}
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{txn.amount_display}</p>
</td>
<td className="px-6 py-4">
<p className={`font-medium ${txn.transaction_type === 'refund' ? 'text-red-600' : 'text-green-600'}`}>
{txn.transaction_type === 'refund' ? '-' : ''}${(txn.net_amount / 100).toFixed(2)}
</p>
{txn.application_fee_amount > 0 && (
<p className="text-xs text-gray-400">-{txn.fee_display} fee</p>
)}
</td>
<td className="px-6 py-4">
{getStatusBadge(txn.status)}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedTransactionId(txn.id);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-brand-600 hover:text-brand-700 hover:bg-brand-50 rounded-lg transition-colors"
>
<Eye size={14} />
View
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
No transactions found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{transactions && transactions.total_pages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p className="text-sm text-gray-500">
Showing {(filters.page! - 1) * filters.page_size! + 1} to{' '}
{Math.min(filters.page! * filters.page_size!, transactions.count)} of {transactions.count}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setFilters({ ...filters, page: filters.page! - 1 })}
disabled={filters.page === 1}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={20} />
</button>
<span className="text-sm text-gray-600">
Page {filters.page} of {transactions.total_pages}
</span>
<button
onClick={() => setFilters({ ...filters, page: filters.page! + 1 })}
disabled={filters.page === transactions.total_pages}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={20} />
</button>
</div>
</div>
)}
</div>
</div>
)}
{activeTab === 'payouts' && (
<div className="space-y-6">
{/* Balance Summary */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-green-100 rounded-lg">
<Wallet className="text-green-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-500">Available for Payout</p>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400" size={20} />
) : (
<p className="text-2xl font-bold text-gray-900">
${((balance?.available_total || 0) / 100).toFixed(2)}
</p>
)}
</div>
</div>
{balance?.available?.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-t border-gray-100">
<span className="text-sm text-gray-500">{item.currency.toUpperCase()}</span>
<span className="font-medium">{item.amount_display}</span>
</div>
))}
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-yellow-100 rounded-lg">
<Clock className="text-yellow-600" size={24} />
</div>
<div>
<p className="text-sm text-gray-500">Pending</p>
{balanceLoading ? (
<Loader2 className="animate-spin text-gray-400" size={20} />
) : (
<p className="text-2xl font-bold text-gray-900">
${((balance?.pending_total || 0) / 100).toFixed(2)}
</p>
)}
</div>
</div>
{balance?.pending?.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-t border-gray-100">
<span className="text-sm text-gray-500">{item.currency.toUpperCase()}</span>
<span className="font-medium">{item.amount_display}</span>
</div>
))}
</div>
</div>
{/* Payouts List */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Payout History</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Payout ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Arrival Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{payoutsLoading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-center">
<Loader2 className="animate-spin text-gray-400 mx-auto" size={24} />
</td>
</tr>
) : payoutsData?.payouts?.length ? (
payoutsData.payouts.map((payout) => (
<tr key={payout.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-6 py-4">
<p className="text-sm font-mono text-gray-600">{payout.id}</p>
</td>
<td className="px-6 py-4">
<p className="font-medium text-gray-900 dark:text-white">{payout.amount_display}</p>
</td>
<td className="px-6 py-4">
{getStatusBadge(payout.status)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{payout.arrival_date ? formatDate(payout.arrival_date) : '-'}
</td>
<td className="px-6 py-4 text-sm text-gray-500 capitalize">
{payout.method}
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
No payouts yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{activeTab === 'settings' && (
<PaymentSettingsSection business={business} />
)}
{/* Transaction Detail Modal */}
{selectedTransactionId && (
<TransactionDetailModal
transactionId={selectedTransactionId}
onClose={() => setSelectedTransactionId(null)}
/>
)}
{/* Export Modal */}
{showExportModal && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold">Export Transactions</h3>
<button onClick={() => setShowExportModal(false)} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Export Format</label>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'csv', label: 'CSV', icon: FileText },
{ id: 'xlsx', label: 'Excel', icon: FileSpreadsheet },
{ id: 'pdf', label: 'PDF', icon: FileText },
{ id: 'quickbooks', label: 'QuickBooks', icon: FileSpreadsheet },
].map((format) => (
<button
key={format.id}
onClick={() => setExportFormat(format.id as any)}
className={`flex items-center gap-2 p-3 rounded-lg border-2 transition-colors ${
exportFormat === format.id
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<format.icon size={18} />
{format.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Date Range (Optional)</label>
<div className="flex items-center gap-2">
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
<span className="text-gray-400">to</span>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
</div>
<button
onClick={handleExport}
disabled={exportMutation.isPending}
className="w-full flex items-center justify-center gap-2 py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{exportMutation.isPending ? (
<>
<Loader2 className="animate-spin" size={18} />
Exporting...
</>
) : (
<>
<Download size={18} />
Export
</>
)}
</button>
</div>
</div>
</div>
</Portal>
)}
</div>
);
}
// Customer View
if (isCustomer && customerProfile) {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Billing</h2>
<p className="text-gray-500 dark:text-gray-400">Manage your payment methods and view invoice history.</p>
</div>
{/* Payment Methods */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Payment Methods</h3>
<button onClick={() => setIsAddCardModalOpen(true)} className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
<Plus size={16} /> Add Card
</button>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{customerProfile.paymentMethods.length > 0 ? customerProfile.paymentMethods.map((pm) => (
<div key={pm.id} className="p-6 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div className="flex items-center gap-4">
<CreditCard className="text-gray-400" size={24} />
<div>
<p className="font-medium text-gray-900 dark:text-white">{pm.brand} ending in {pm.last4}</p>
{pm.isDefault && <span className="text-xs font-medium text-green-600 dark:text-green-400">Default</span>}
</div>
</div>
<div className="flex items-center gap-2">
{!pm.isDefault && (
<button onClick={() => handleSetDefault(pm.id)} className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 font-medium">
<Star size={14} /> Set as Default
</button>
)}
<button onClick={() => handleDeleteMethod(pm.id)} className="p-2 text-gray-400 hover:text-red-500 dark:hover:text-red-400">
<Trash2 size={16} />
</button>
</div>
</div>
)) : <div className="p-8 text-center text-gray-500 dark:text-gray-400">No payment methods on file.</div>}
</div>
</div>
{/* Invoice History */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">Invoice History</h3>
</div>
<div className="p-8 text-center text-gray-500">
No invoices yet.
</div>
</div>
{/* Add Card Modal */}
{isAddCardModalOpen && (
<Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setIsAddCardModalOpen(false)}>
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold">Add New Card</h3>
<button onClick={() => setIsAddCardModalOpen(false)}><X size={20} /></button>
</div>
<form onSubmit={handleAddCard} className="p-6 space-y-4">
<div><label className="text-sm font-medium">Card Number</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"> 4242</div></div>
<div><label className="text-sm font-medium">Cardholder Name</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">{effectiveUser.name}</div></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="text-sm font-medium">Expiry</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600">12 / 2028</div></div>
<div><label className="text-sm font-medium">CVV</label><div className="mt-1 p-3 border rounded-lg bg-gray-50 dark:bg-gray-700 dark:border-gray-600"></div></div>
</div>
<p className="text-xs text-gray-400 text-center">This is a simulated form. No real card data is required.</p>
<button type="submit" className="w-full py-3 bg-brand-600 text-white font-semibold rounded-lg hover:bg-brand-700">Add Card</button>
</form>
</div>
</div>
</Portal>
)}
</div>
);
}
return <div>Access Denied or User not found.</div>;
};
export default Payments;