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>
871 lines
42 KiB
TypeScript
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;
|