754 lines
33 KiB
TypeScript
754 lines
33 KiB
TypeScript
/**
|
|
* Admin Payments Page
|
|
* Tabs: All Payments, Pending Approvals (approve/reject), Payment Methods (country-level configs + per-account methods)
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { Filter, Loader2, AlertCircle, Check, X, RefreshCw, Plus, Trash, Star } from 'lucide-react';
|
|
import { Card } from '../../components/ui/card';
|
|
import Badge from '../../components/ui/badge/Badge';
|
|
import {
|
|
getAdminPayments,
|
|
getPendingPayments,
|
|
approvePayment,
|
|
rejectPayment,
|
|
getAdminPaymentMethodConfigs,
|
|
createAdminPaymentMethodConfig,
|
|
updateAdminPaymentMethodConfig,
|
|
deleteAdminPaymentMethodConfig,
|
|
getAdminAccountPaymentMethods,
|
|
createAdminAccountPaymentMethod,
|
|
updateAdminAccountPaymentMethod,
|
|
deleteAdminAccountPaymentMethod,
|
|
setAdminDefaultAccountPaymentMethod,
|
|
getAdminUsers,
|
|
type Payment,
|
|
type PaymentMethod,
|
|
type PaymentMethodConfig,
|
|
type AdminAccountPaymentMethod,
|
|
type AdminUser,
|
|
} from '../../services/billing.api';
|
|
|
|
type AdminPayment = Payment & { account_name?: string };
|
|
type TabType = 'all' | 'pending' | 'methods';
|
|
|
|
export default function AdminAllPaymentsPage() {
|
|
const [payments, setPayments] = useState<AdminPayment[]>([]);
|
|
const [pendingPayments, setPendingPayments] = useState<AdminPayment[]>([]);
|
|
const [paymentConfigs, setPaymentConfigs] = useState<PaymentMethodConfig[]>([]);
|
|
const [accounts, setAccounts] = useState<AdminUser[]>([]);
|
|
const [accountPaymentMethods, setAccountPaymentMethods] = useState<AdminAccountPaymentMethod[]>([]);
|
|
const [accountIdFilter, setAccountIdFilter] = useState<string>('');
|
|
const [selectedConfigIdForAccount, setSelectedConfigIdForAccount] = useState<number | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string>('');
|
|
const [statusFilter, setStatusFilter] = useState('all');
|
|
const [activeTab, setActiveTab] = useState<TabType>('all');
|
|
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
|
|
const [rejectNotes, setRejectNotes] = useState<Record<number, string>>({});
|
|
const [newConfig, setNewConfig] = useState<{
|
|
country_code: string;
|
|
payment_method: PaymentMethod['type'];
|
|
display_name: string;
|
|
instructions?: string;
|
|
sort_order?: number;
|
|
is_enabled?: boolean;
|
|
}>({
|
|
country_code: '*',
|
|
payment_method: 'bank_transfer',
|
|
display_name: '',
|
|
instructions: '',
|
|
sort_order: 0,
|
|
is_enabled: true,
|
|
});
|
|
const [editingConfigId, setEditingConfigId] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadAll();
|
|
}, []);
|
|
|
|
const loadAll = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [allData, pendingData, configsData, usersData] = await Promise.all([
|
|
getAdminPayments(),
|
|
getPendingPayments(),
|
|
getAdminPaymentMethodConfigs(),
|
|
getAdminUsers(),
|
|
]);
|
|
setPayments(allData.results || []);
|
|
setPendingPayments(pendingData.results || []);
|
|
setPaymentConfigs(configsData.results || []);
|
|
setAccounts(usersData.results || []);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to load payments');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredPayments = payments.filter((payment) => statusFilter === 'all' || payment.status === statusFilter);
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'succeeded':
|
|
case 'completed':
|
|
return 'success';
|
|
case 'processing':
|
|
case 'pending':
|
|
case 'pending_approval':
|
|
return 'warning';
|
|
case 'refunded':
|
|
return 'info';
|
|
default:
|
|
return 'error';
|
|
}
|
|
};
|
|
|
|
const handleApprove = async (id: number) => {
|
|
try {
|
|
setActionLoadingId(id);
|
|
await approvePayment(id);
|
|
await loadAll();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to approve payment');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleReject = async (id: number) => {
|
|
try {
|
|
setActionLoadingId(id);
|
|
await rejectPayment(id, { notes: rejectNotes[id] || '' });
|
|
await loadAll();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to reject payment');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
// Payment method configs (country-level)
|
|
const handleSaveConfig = async () => {
|
|
if (!newConfig.display_name.trim()) {
|
|
setError('Payment method display name is required');
|
|
return;
|
|
}
|
|
if (!newConfig.payment_method) {
|
|
setError('Payment method type is required');
|
|
return;
|
|
}
|
|
try {
|
|
setActionLoadingId(-1);
|
|
if (editingConfigId) {
|
|
await updateAdminPaymentMethodConfig(editingConfigId, {
|
|
country_code: newConfig.country_code || '*',
|
|
payment_method: newConfig.payment_method,
|
|
display_name: newConfig.display_name,
|
|
instructions: newConfig.instructions,
|
|
sort_order: newConfig.sort_order,
|
|
is_enabled: newConfig.is_enabled ?? true,
|
|
});
|
|
} else {
|
|
await createAdminPaymentMethodConfig({
|
|
country_code: newConfig.country_code || '*',
|
|
payment_method: newConfig.payment_method,
|
|
display_name: newConfig.display_name,
|
|
instructions: newConfig.instructions,
|
|
sort_order: newConfig.sort_order,
|
|
is_enabled: newConfig.is_enabled ?? true,
|
|
});
|
|
}
|
|
setNewConfig({
|
|
country_code: '*',
|
|
payment_method: 'bank_transfer',
|
|
display_name: '',
|
|
instructions: '',
|
|
sort_order: 0,
|
|
is_enabled: true,
|
|
});
|
|
setEditingConfigId(null);
|
|
const cfgs = await getAdminPaymentMethodConfigs();
|
|
setPaymentConfigs(cfgs.results || []);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to add payment method config');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleToggleConfigEnabled = async (cfg: PaymentMethodConfig) => {
|
|
try {
|
|
setActionLoadingId(cfg.id);
|
|
await updateAdminPaymentMethodConfig(cfg.id, { is_enabled: !cfg.is_enabled });
|
|
const cfgs = await getAdminPaymentMethodConfigs();
|
|
setPaymentConfigs(cfgs.results || []);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to update payment method config');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleDeleteConfig = async (id: number) => {
|
|
try {
|
|
setActionLoadingId(id);
|
|
await deleteAdminPaymentMethodConfig(id);
|
|
const cfgs = await getAdminPaymentMethodConfigs();
|
|
setPaymentConfigs(cfgs.results || []);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to delete payment method config');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleEditConfig = (cfg: PaymentMethodConfig) => {
|
|
setEditingConfigId(cfg.id);
|
|
setNewConfig({
|
|
country_code: cfg.country_code,
|
|
payment_method: cfg.payment_method,
|
|
display_name: cfg.display_name,
|
|
instructions: cfg.instructions,
|
|
sort_order: cfg.sort_order,
|
|
is_enabled: cfg.is_enabled,
|
|
});
|
|
};
|
|
|
|
const handleCancelConfigEdit = () => {
|
|
setEditingConfigId(null);
|
|
setNewConfig({
|
|
country_code: '*',
|
|
payment_method: 'bank_transfer',
|
|
display_name: '',
|
|
instructions: '',
|
|
sort_order: 0,
|
|
is_enabled: true,
|
|
});
|
|
};
|
|
|
|
// Account payment methods
|
|
const handleLoadAccountMethods = async () => {
|
|
const accountId = accountIdFilter.trim();
|
|
if (!accountId) {
|
|
setAccountPaymentMethods([]);
|
|
return;
|
|
}
|
|
try {
|
|
setActionLoadingId(-3);
|
|
const data = await getAdminAccountPaymentMethods({ account_id: Number(accountId) });
|
|
setAccountPaymentMethods(data.results || []);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to load account payment methods');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
// Associate an existing country-level config to the account (one per account)
|
|
const handleAssociateConfigToAccount = async () => {
|
|
const accountId = accountIdFilter.trim();
|
|
if (!accountId) {
|
|
setError('Select an account first');
|
|
return;
|
|
}
|
|
if (!selectedConfigIdForAccount) {
|
|
setError('Select a payment method config to assign');
|
|
return;
|
|
}
|
|
const cfg = paymentConfigs.find((c) => c.id === selectedConfigIdForAccount);
|
|
if (!cfg) {
|
|
setError('Selected config not found');
|
|
return;
|
|
}
|
|
try {
|
|
setActionLoadingId(-2);
|
|
// Create or replace with the chosen config; treat as association.
|
|
const created = await createAdminAccountPaymentMethod({
|
|
account: Number(accountId),
|
|
type: cfg.payment_method,
|
|
display_name: cfg.display_name,
|
|
instructions: cfg.instructions,
|
|
is_enabled: cfg.is_enabled,
|
|
is_default: true,
|
|
});
|
|
// Remove extras if more than one exists for this account to enforce single association.
|
|
const refreshed = await getAdminAccountPaymentMethods({ account_id: Number(accountId) });
|
|
const others = (refreshed.results || []).filter((m) => m.id !== created.id);
|
|
for (const other of others) {
|
|
await deleteAdminAccountPaymentMethod(other.id);
|
|
}
|
|
await handleLoadAccountMethods();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to assign payment method to account');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAccountMethod = async (id: number | string) => {
|
|
try {
|
|
setActionLoadingId(Number(id));
|
|
await deleteAdminAccountPaymentMethod(id);
|
|
await handleLoadAccountMethods();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to delete account payment method');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleSetDefaultAccountMethod = async (id: number | string) => {
|
|
try {
|
|
setActionLoadingId(Number(id));
|
|
await setAdminDefaultAccountPaymentMethod(id);
|
|
await handleLoadAccountMethods();
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to set default account payment method');
|
|
} finally {
|
|
setActionLoadingId(null);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const renderPaymentsTable = (rows: AdminPayment[]) => (
|
|
<Card className="overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Account</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Invoice</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Method</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{rows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">No payments found</td>
|
|
</tr>
|
|
) : (
|
|
rows.map((payment) => (
|
|
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td className="px-6 py-4 font-medium">{payment.account_name}</td>
|
|
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
{payment.invoice_number || payment.invoice_id || '—'}
|
|
</td>
|
|
<td className="px-6 py-4 font-semibold">{payment.currency} {payment.amount}</td>
|
|
<td className="px-6 py-4 text-sm capitalize">{payment.payment_method.replace('_', ' ')}</td>
|
|
<td className="px-6 py-4">
|
|
<Badge variant="light" color={getStatusColor(payment.status)}>
|
|
{payment.status}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600">
|
|
{new Date(payment.created_at).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<button className="text-blue-600 hover:text-blue-700 text-sm">View</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
);
|
|
|
|
const renderPendingTable = () => (
|
|
<Card className="overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Account</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Invoice</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Method</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Reference</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{pendingPayments.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">No pending payments</td>
|
|
</tr>
|
|
) : (
|
|
pendingPayments.map((payment) => (
|
|
<tr key={payment.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td className="px-6 py-4 font-medium">{payment.account_name}</td>
|
|
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
{payment.invoice_number || payment.invoice_id || '—'}
|
|
</td>
|
|
<td className="px-6 py-4 font-semibold">{payment.currency} {payment.amount}</td>
|
|
<td className="px-6 py-4 text-sm capitalize">{payment.payment_method.replace('_', ' ')}</td>
|
|
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
{payment.transaction_reference || '—'}
|
|
</td>
|
|
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
|
|
<button
|
|
className="inline-flex items-center gap-1 text-green-600 hover:text-green-700 text-sm px-2 py-1 border border-green-200 rounded"
|
|
disabled={actionLoadingId === payment.id}
|
|
onClick={() => handleApprove(payment.id as number)}
|
|
>
|
|
{actionLoadingId === payment.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
|
Approve
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
className="text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800"
|
|
placeholder="Rejection notes"
|
|
value={rejectNotes[payment.id as number] || ''}
|
|
onChange={(e) => setRejectNotes({ ...rejectNotes, [payment.id as number]: e.target.value })}
|
|
/>
|
|
<button
|
|
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 text-sm px-2 py-1 border border-red-200 rounded"
|
|
disabled={actionLoadingId === payment.id}
|
|
onClick={() => handleReject(payment.id as number)}
|
|
>
|
|
{actionLoadingId === payment.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <X className="w-4 h-4" />}
|
|
Reject
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
);
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Payments</h1>
|
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
Admin-only billing management
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={loadAll}
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm border border-gray-300 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-3">
|
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
|
<p className="text-red-800 dark:text-red-200">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-4 flex items-center gap-4">
|
|
<div className="flex gap-2">
|
|
<button
|
|
className={`px-3 py-2 rounded-lg text-sm border ${activeTab === 'all' ? 'border-blue-500 text-blue-600' : 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-300'}`}
|
|
onClick={() => setActiveTab('all')}
|
|
>
|
|
All Payments
|
|
</button>
|
|
<button
|
|
className={`px-3 py-2 rounded-lg text-sm border ${activeTab === 'pending' ? 'border-blue-500 text-blue-600' : 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-300'}`}
|
|
onClick={() => setActiveTab('pending')}
|
|
>
|
|
Pending Approvals
|
|
</button>
|
|
<button
|
|
className={`px-3 py-2 rounded-lg text-sm border ${activeTab === 'methods' ? 'border-blue-500 text-blue-600' : 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-300'}`}
|
|
onClick={() => setActiveTab('methods')}
|
|
>
|
|
Payment Methods
|
|
</button>
|
|
</div>
|
|
{activeTab === 'all' && (
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="w-5 h-5 text-gray-400" />
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
|
|
>
|
|
<option value="all">All Status</option>
|
|
<option value="pending_approval">Pending Approval</option>
|
|
<option value="processing">Processing</option>
|
|
<option value="succeeded">Succeeded</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="failed">Failed</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
<option value="refunded">Refunded</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{activeTab === 'all' && renderPaymentsTable(filteredPayments)}
|
|
{activeTab === 'pending' && renderPendingTable()}
|
|
{activeTab === 'methods' && (
|
|
<div className="space-y-6">
|
|
{/* Payment Method Configs (country-level) */}
|
|
<Card className="p-4">
|
|
<h3 className="text-lg font-semibold mb-3">Payment Method Configs (country-level)</h3>
|
|
<div className="flex flex-col lg:flex-row gap-3">
|
|
<input
|
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded w-32"
|
|
placeholder="Country (e.g., *, US)"
|
|
value={newConfig.country_code}
|
|
onChange={(e) => setNewConfig({ ...newConfig, country_code: e.target.value })}
|
|
/>
|
|
<select
|
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded w-40"
|
|
value={newConfig.payment_method}
|
|
onChange={(e) => setNewConfig({ ...newConfig, payment_method: e.target.value as PaymentMethod['type'] })}
|
|
>
|
|
<option value="bank_transfer">Bank Transfer</option>
|
|
<option value="local_wallet">Manual (local wallet)</option>
|
|
<option value="stripe">Stripe</option>
|
|
<option value="paypal">PayPal</option>
|
|
</select>
|
|
<input
|
|
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded"
|
|
placeholder="Display name"
|
|
value={newConfig.display_name}
|
|
onChange={(e) => setNewConfig({ ...newConfig, display_name: e.target.value })}
|
|
/>
|
|
<input
|
|
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded"
|
|
placeholder="Instructions (optional)"
|
|
value={newConfig.instructions || ''}
|
|
onChange={(e) => setNewConfig({ ...newConfig, instructions: e.target.value })}
|
|
/>
|
|
<input
|
|
type="number"
|
|
className="w-28 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded"
|
|
placeholder="Sort"
|
|
value={newConfig.sort_order ?? 0}
|
|
onChange={(e) => setNewConfig({ ...newConfig, sort_order: Number(e.target.value) })}
|
|
/>
|
|
<label className="inline-flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!newConfig.is_enabled}
|
|
onChange={(e) => setNewConfig({ ...newConfig, is_enabled: e.target.checked })}
|
|
/>
|
|
Enabled
|
|
</label>
|
|
<button
|
|
className="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
|
onClick={handleSaveConfig}
|
|
disabled={actionLoadingId === -1}
|
|
>
|
|
{actionLoadingId === -1 ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
|
{editingConfigId ? 'Save' : 'Add'}
|
|
</button>
|
|
{editingConfigId && (
|
|
<button
|
|
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50"
|
|
onClick={handleCancelConfigEdit}
|
|
disabled={actionLoadingId === -1}
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Country</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Enabled</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Instructions</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{paymentConfigs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">No payment method configs</td>
|
|
</tr>
|
|
) : (
|
|
paymentConfigs.map((cfg) => (
|
|
<tr key={cfg.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td className="px-6 py-4 font-medium">{cfg.country_code}</td>
|
|
<td className="px-6 py-4 font-medium">{cfg.display_name}</td>
|
|
<td className="px-6 py-4 text-sm capitalize">{cfg.payment_method.replace('_', ' ')}</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
<button
|
|
className="text-blue-600 hover:text-blue-700 text-sm mr-3"
|
|
onClick={() => handleToggleConfigEnabled(cfg)}
|
|
disabled={actionLoadingId === cfg.id}
|
|
>
|
|
{actionLoadingId === cfg.id ? <Loader2 className="w-4 h-4 animate-spin" /> : cfg.is_enabled ? 'Disable' : 'Enable'}
|
|
</button>
|
|
<button
|
|
className="text-blue-600 hover:text-blue-700 text-sm"
|
|
onClick={() => handleEditConfig(cfg)}
|
|
disabled={actionLoadingId === cfg.id}
|
|
>
|
|
Edit
|
|
</button>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
{cfg.instructions || '—'}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<button
|
|
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 text-sm px-2 py-1 border border-red-200 rounded"
|
|
onClick={() => handleDeleteConfig(cfg.id)}
|
|
disabled={actionLoadingId === cfg.id}
|
|
>
|
|
{actionLoadingId === cfg.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash className="w-4 h-4" />}
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Account Payment Methods (associate existing configs only) */}
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-lg font-semibold">Account Payment Methods (association)</h3>
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded w-52"
|
|
value={accountIdFilter}
|
|
onChange={(e) => setAccountIdFilter(e.target.value)}
|
|
>
|
|
<option value="">Select account</option>
|
|
{accounts.map((acc) => (
|
|
<option key={acc.id} value={acc.id}>
|
|
{acc.account_name || acc.email || acc.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50"
|
|
onClick={handleLoadAccountMethods}
|
|
disabled={!accountIdFilter}
|
|
>
|
|
{actionLoadingId === -3 ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
|
Load
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col lg:flex-row gap-3 mb-4">
|
|
<select
|
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded w-64"
|
|
value={selectedConfigIdForAccount ?? ''}
|
|
onChange={(e) => setSelectedConfigIdForAccount(e.target.value ? Number(e.target.value) : null)}
|
|
disabled={!accountIdFilter}
|
|
>
|
|
<option value="">Select payment method config</option>
|
|
{paymentConfigs.map((cfg) => (
|
|
<option key={cfg.id} value={cfg.id}>
|
|
{cfg.display_name} ({cfg.payment_method.replace('_', ' ')} - {cfg.country_code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className="inline-flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
|
onClick={handleAssociateConfigToAccount}
|
|
disabled={!accountIdFilter || !selectedConfigIdForAccount || actionLoadingId === -2}
|
|
>
|
|
{actionLoadingId === -2 ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
|
Assign to account
|
|
</button>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Only one payment method per account; assigning replaces existing.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Account</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Enabled</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Default</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Instructions</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{accountPaymentMethods.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">No account payment methods</td>
|
|
</tr>
|
|
) : (
|
|
accountPaymentMethods.map((m) => (
|
|
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td className="px-6 py-4 font-medium">{m.account}</td>
|
|
<td className="px-6 py-4 font-medium">{m.display_name}</td>
|
|
<td className="px-6 py-4 text-sm capitalize">{m.type.replace('_', ' ')}</td>
|
|
<td className="px-6 py-4 text-sm">{m.is_enabled ? 'Yes' : 'No'}</td>
|
|
<td className="px-6 py-4 text-sm">{m.is_default ? <Star className="w-4 h-4 text-yellow-500" /> : '—'}</td>
|
|
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
{m.instructions || '—'}
|
|
</td>
|
|
<td className="px-6 py-4 text-right space-x-2">
|
|
{!m.is_default && (
|
|
<button
|
|
className="text-blue-600 hover:text-blue-700 text-sm"
|
|
onClick={() => handleSetDefaultAccountMethod(m.id)}
|
|
disabled={actionLoadingId === Number(m.id)}
|
|
>
|
|
{actionLoadingId === Number(m.id) ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Set default'}
|
|
</button>
|
|
)}
|
|
<button
|
|
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 text-sm px-2 py-1 border border-red-200 rounded"
|
|
onClick={() => handleDeleteAccountMethod(m.id)}
|
|
disabled={actionLoadingId === Number(m.id)}
|
|
>
|
|
{actionLoadingId === Number(m.id) ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash className="w-4 h-4" />}
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|