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>
This commit is contained in:
549
frontend/src/components/TransactionDetailModal.tsx
Normal file
549
frontend/src/components/TransactionDetailModal.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* Transaction Detail Modal
|
||||
*
|
||||
* Displays comprehensive transaction information and provides refund functionality.
|
||||
* Supports both partial and full refunds with reason selection.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
CreditCard,
|
||||
User,
|
||||
Mail,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
RefreshCcw,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Receipt,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
ArrowLeftRight,
|
||||
Percent,
|
||||
} from 'lucide-react';
|
||||
import { TransactionDetail, RefundInfo, RefundRequest } from '../api/payments';
|
||||
import { useTransactionDetail, useRefundTransaction } from '../hooks/useTransactionAnalytics';
|
||||
import Portal from './Portal';
|
||||
|
||||
interface TransactionDetailModalProps {
|
||||
transactionId: number | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
transactionId,
|
||||
onClose,
|
||||
}) => {
|
||||
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
|
||||
const refundMutation = useRefundTransaction();
|
||||
|
||||
// Refund form state
|
||||
const [showRefundForm, setShowRefundForm] = useState(false);
|
||||
const [refundType, setRefundType] = useState<'full' | 'partial'>('full');
|
||||
const [refundAmount, setRefundAmount] = useState('');
|
||||
const [refundReason, setRefundReason] = useState<RefundRequest['reason']>('requested_by_customer');
|
||||
const [refundError, setRefundError] = useState<string | null>(null);
|
||||
|
||||
if (!transactionId) return null;
|
||||
|
||||
const handleRefund = async () => {
|
||||
if (!transaction) return;
|
||||
|
||||
setRefundError(null);
|
||||
|
||||
const request: RefundRequest = {
|
||||
reason: refundReason,
|
||||
};
|
||||
|
||||
// For partial refunds, include the amount
|
||||
if (refundType === 'partial') {
|
||||
const amountCents = Math.round(parseFloat(refundAmount) * 100);
|
||||
if (isNaN(amountCents) || amountCents <= 0) {
|
||||
setRefundError('Please enter a valid refund amount');
|
||||
return;
|
||||
}
|
||||
if (amountCents > transaction.refundable_amount) {
|
||||
setRefundError(`Amount exceeds refundable amount ($${(transaction.refundable_amount / 100).toFixed(2)})`);
|
||||
return;
|
||||
}
|
||||
request.amount = amountCents;
|
||||
}
|
||||
|
||||
try {
|
||||
await refundMutation.mutateAsync({
|
||||
transactionId: transaction.id,
|
||||
request,
|
||||
});
|
||||
setShowRefundForm(false);
|
||||
setRefundAmount('');
|
||||
} catch (err: any) {
|
||||
setRefundError(err.response?.data?.error || 'Failed to process refund');
|
||||
}
|
||||
};
|
||||
|
||||
// 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={14} /> },
|
||||
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: <Clock size={14} /> },
|
||||
failed: { bg: 'bg-red-100', text: 'text-red-800', icon: <XCircle size={14} /> },
|
||||
refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: <RefreshCcw size={14} /> },
|
||||
partially_refunded: { bg: 'bg-orange-100', text: 'text-orange-800', icon: <RefreshCcw size={14} /> },
|
||||
};
|
||||
const style = styles[status] || styles.pending;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full ${style.bg} ${style.text}`}>
|
||||
{style.icon}
|
||||
{status.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</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: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Format timestamp for refunds
|
||||
const formatRefundDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Get payment method display
|
||||
const getPaymentMethodDisplay = () => {
|
||||
if (!transaction?.payment_method_info) return null;
|
||||
|
||||
const pm = transaction.payment_method_info;
|
||||
if (pm.type === 'card') {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<CreditCard className="text-gray-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{pm.brand} **** {pm.last4}
|
||||
</p>
|
||||
{pm.exp_month && pm.exp_year && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Expires {pm.exp_month}/{pm.exp_year}
|
||||
{pm.funding && ` (${pm.funding})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<DollarSign className="text-gray-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 capitalize">{pm.type.replace('_', ' ')}</p>
|
||||
{pm.bank_name && <p className="text-sm text-gray-500">{pm.bank_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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-2xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Transaction Details
|
||||
</h3>
|
||||
{transaction && (
|
||||
<p className="text-sm text-gray-500 font-mono">
|
||||
{transaction.stripe_payment_intent_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-gray-400" size={32} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={18} />
|
||||
<p className="font-medium">Failed to load transaction details</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transaction && (
|
||||
<>
|
||||
{/* Status & Amount */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
{getStatusBadge(transaction.status)}
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">
|
||||
{transaction.amount_display}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{transaction.transaction_type.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</p>
|
||||
</div>
|
||||
{transaction.can_refund && !showRefundForm && (
|
||||
<button
|
||||
onClick={() => setShowRefundForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
Issue Refund
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refund Form */}
|
||||
{showRefundForm && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-red-800">
|
||||
<RefreshCcw size={18} />
|
||||
<h4 className="font-semibold">Issue Refund</h4>
|
||||
</div>
|
||||
|
||||
{/* Refund Type */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="refundType"
|
||||
checked={refundType === 'full'}
|
||||
onChange={() => setRefundType('full')}
|
||||
className="text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Full refund (${(transaction.refundable_amount / 100).toFixed(2)})
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="refundType"
|
||||
checked={refundType === 'partial'}
|
||||
onChange={() => setRefundType('partial')}
|
||||
className="text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Partial refund</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Partial Amount */}
|
||||
{refundType === 'partial' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Refund Amount (max ${(transaction.refundable_amount / 100).toFixed(2)})
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={(transaction.refundable_amount / 100).toFixed(2)}
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full pl-7 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Refund Reason
|
||||
</label>
|
||||
<select
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value as RefundRequest['reason'])}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
<option value="requested_by_customer">Requested by customer</option>
|
||||
<option value="duplicate">Duplicate charge</option>
|
||||
<option value="fraudulent">Fraudulent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{refundError && (
|
||||
<div className="flex items-center gap-2 text-red-600 text-sm">
|
||||
<AlertCircle size={16} />
|
||||
{refundError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRefund}
|
||||
disabled={refundMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{refundMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCcw size={16} />
|
||||
Confirm Refund
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRefundForm(false);
|
||||
setRefundError(null);
|
||||
setRefundAmount('');
|
||||
}}
|
||||
disabled={refundMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Customer Info */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<User size={16} />
|
||||
Customer
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
||||
{transaction.customer_name && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User size={14} className="text-gray-400" />
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
{transaction.customer_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{transaction.customer_email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail size={14} className="text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{transaction.customer_email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Breakdown */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<DollarSign size={16} />
|
||||
Amount Breakdown
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Gross Amount</span>
|
||||
<span className="font-medium">{transaction.amount_display}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Platform Fee</span>
|
||||
<span className="text-red-600">-{transaction.fee_display}</span>
|
||||
</div>
|
||||
{transaction.total_refunded > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Refunded</span>
|
||||
<span className="text-orange-600">
|
||||
-${(transaction.total_refunded / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Net Amount</span>
|
||||
<span className="font-bold text-green-600">
|
||||
${(transaction.net_amount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{transaction.payment_method_info && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<CreditCard size={16} />
|
||||
Payment Method
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
{getPaymentMethodDisplay()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{transaction.description && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Receipt size={16} />
|
||||
Description
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300">{transaction.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refund History */}
|
||||
{transaction.refunds && transaction.refunds.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<RefreshCcw size={16} />
|
||||
Refund History
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{transaction.refunds.map((refund: RefundInfo) => (
|
||||
<div
|
||||
key={refund.id}
|
||||
className="bg-orange-50 border border-orange-200 rounded-lg p-4 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-orange-800">{refund.amount_display}</p>
|
||||
<p className="text-sm text-orange-600">
|
||||
{refund.reason
|
||||
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
: 'No reason provided'}
|
||||
</p>
|
||||
<p className="text-xs text-orange-500 mt-1">
|
||||
{formatRefundDate(refund.created)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
refund.status === 'succeeded'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{refund.status === 'succeeded' ? (
|
||||
<CheckCircle size={12} />
|
||||
) : (
|
||||
<Clock size={12} />
|
||||
)}
|
||||
{refund.status}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-1 font-mono">{refund.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Calendar size={16} />
|
||||
Timeline
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-gray-600">Created</span>
|
||||
<span className="ml-auto text-gray-900 dark:text-white">
|
||||
{formatDate(transaction.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.updated_at !== transaction.created_at && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-gray-600">Last Updated</span>
|
||||
<span className="ml-auto text-gray-900 dark:text-white">
|
||||
{formatDate(transaction.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowLeftRight size={16} />
|
||||
Technical Details
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2 font-mono text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Payment Intent</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{transaction.stripe_payment_intent_id}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.stripe_charge_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Charge ID</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{transaction.stripe_charge_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Transaction ID</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{transaction.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Currency</span>
|
||||
<span className="text-gray-700 dark:text-gray-300 uppercase">
|
||||
{transaction.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionDetailModal;
|
||||
Reference in New Issue
Block a user