feat: add admin UI for credits, transfers, and plan entitlements
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
- Add PlanEntitlementsEditor component for Pay-Per-Court settings - Add EntitlementsConfigModal for editing plan entitlements - Update PlanCard with entitlements badges and configure button - Update MembershipPlansComponent to fetch/display entitlements - Create credits management page with balance list and adjustment modal - Create transfers management page with filtering and stats - Add Credits and Transfers tabs to ClubDetailTabs navigation - Add API client functions for credits and transfers endpoints - Update facility-admin types with credit and transfer interfacesmaster
parent
9f059bcbfe
commit
7055eb2f43
@ -0,0 +1,304 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Loader2, Plus, Minus, History, AlertCircle } from 'lucide-react';
|
||||||
|
import Modal from '@/src/components/modals/Modal';
|
||||||
|
import ModalHeader from '@/src/components/modals/ModalHeader';
|
||||||
|
import ModalBody from '@/src/components/modals/ModalBody';
|
||||||
|
import ModalFooter from '@/src/components/modals/ModalFooter';
|
||||||
|
import { getUserCreditDetail, adjustUserCredit } from '@/src/lib/api/facility-admin';
|
||||||
|
import type { MemberCreditBalance, CreditLedgerEntry } from '@/src/types/facility-admin';
|
||||||
|
|
||||||
|
interface CreditDetailModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
facilityId: number;
|
||||||
|
user: MemberCreditBalance;
|
||||||
|
onBalanceChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreditDetailModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
facilityId,
|
||||||
|
user,
|
||||||
|
onBalanceChange
|
||||||
|
}: CreditDetailModalProps) {
|
||||||
|
const [ledgerEntries, setLedgerEntries] = useState<CreditLedgerEntry[]>([]);
|
||||||
|
const [balance, setBalance] = useState(user.balance_cents);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Adjustment form state
|
||||||
|
const [showAdjustForm, setShowAdjustForm] = useState(false);
|
||||||
|
const [adjustmentType, setAdjustmentType] = useState<'add' | 'subtract'>('add');
|
||||||
|
const [adjustmentAmount, setAdjustmentAmount] = useState('');
|
||||||
|
const [adjustmentReason, setAdjustmentReason] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchLedger();
|
||||||
|
}
|
||||||
|
}, [isOpen, user.app_user_id]);
|
||||||
|
|
||||||
|
async function fetchLedger() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await getUserCreditDetail(facilityId, user.app_user_id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setLedgerEntries(result.data.ledger_entries);
|
||||||
|
setBalance(result.data.balance_cents);
|
||||||
|
} else {
|
||||||
|
setError(result.error.detail || 'Failed to load credit history');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdjustment(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const amountCents = Math.round(parseFloat(adjustmentAmount) * 100);
|
||||||
|
if (isNaN(amountCents) || amountCents <= 0) {
|
||||||
|
setError('Please enter a valid amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adjustmentReason.trim()) {
|
||||||
|
setError('Please enter a reason for the adjustment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const finalAmount = adjustmentType === 'subtract' ? -amountCents : amountCents;
|
||||||
|
const result = await adjustUserCredit(facilityId, user.app_user_id, {
|
||||||
|
amount_cents: finalAmount,
|
||||||
|
reason: adjustmentReason.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setBalance(result.data.new_balance_cents);
|
||||||
|
setShowAdjustForm(false);
|
||||||
|
setAdjustmentAmount('');
|
||||||
|
setAdjustmentReason('');
|
||||||
|
fetchLedger();
|
||||||
|
onBalanceChange();
|
||||||
|
} else {
|
||||||
|
setError(result.error.detail || 'Failed to adjust credit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(cents: number, currency: string = 'CHF'): string {
|
||||||
|
const sign = cents >= 0 ? '+' : '';
|
||||||
|
return `${sign}${currency} ${(cents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntryType(type: string): string {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'escrow_hold': 'Escrow Hold',
|
||||||
|
'escrow_release': 'Escrow Release',
|
||||||
|
'escrow_capture': 'Escrow Capture',
|
||||||
|
'manual_adjustment': 'Manual Adjustment',
|
||||||
|
'refund': 'Refund',
|
||||||
|
'payment': 'Payment',
|
||||||
|
'joiner_refund': 'Joiner Refund'
|
||||||
|
};
|
||||||
|
return typeMap[type] || type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||||
|
<ModalHeader onClose={onClose}>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">{user.display_name}</div>
|
||||||
|
<div className="text-sm font-normal text-gray-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Balance */}
|
||||||
|
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-4 mb-6">
|
||||||
|
<div className="text-sm text-gray-600 mb-1">Current Balance</div>
|
||||||
|
<div className={`text-3xl font-bold ${
|
||||||
|
balance > 0 ? 'text-emerald-600' :
|
||||||
|
balance < 0 ? 'text-red-600' : 'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
CHF {(balance / 100).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Adjustment Form */}
|
||||||
|
{showAdjustForm ? (
|
||||||
|
<form onSubmit={handleAdjustment} className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">Adjust Balance</h3>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdjustmentType('add')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
adjustmentType === 'add'
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Credit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdjustmentType('subtract')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
adjustmentType === 'subtract'
|
||||||
|
? 'bg-red-600 text-white'
|
||||||
|
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4" />
|
||||||
|
Subtract Credit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Amount (CHF)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
value={adjustmentAmount}
|
||||||
|
onChange={e => setAdjustmentAmount(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
|
placeholder="10.00"
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Reason
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={adjustmentReason}
|
||||||
|
onChange={e => setAdjustmentReason(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
|
placeholder="e.g., Refund for cancelled booking"
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAdjustForm(false);
|
||||||
|
setAdjustmentAmount('');
|
||||||
|
setAdjustmentReason('');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium transition-colors"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className={`flex-1 px-4 py-2 text-white font-semibold rounded-lg transition-colors ${
|
||||||
|
adjustmentType === 'add'
|
||||||
|
? 'bg-emerald-600 hover:bg-emerald-700'
|
||||||
|
: 'bg-red-600 hover:bg-red-700'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{submitting ? 'Processing...' : `${adjustmentType === 'add' ? 'Add' : 'Subtract'} CHF ${adjustmentAmount || '0.00'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdjustForm(true)}
|
||||||
|
className="w-full mb-6 px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 font-medium transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Adjust Balance
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ledger History */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<History className="w-5 h-5 text-gray-500" />
|
||||||
|
<h3 className="font-semibold text-gray-800">Transaction History</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 text-purple-600 animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-600">Loading history...</span>
|
||||||
|
</div>
|
||||||
|
) : ledgerEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-gray-500">No transactions yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||||
|
{ledgerEntries.map(entry => (
|
||||||
|
<div
|
||||||
|
key={entry.ledger_entry_id}
|
||||||
|
className="flex items-start justify-between p-3 bg-white border border-gray-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{formatEntryType(entry.entry_type)}
|
||||||
|
</div>
|
||||||
|
{entry.description && (
|
||||||
|
<div className="text-sm text-gray-500">{entry.description}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
{entry.created_at
|
||||||
|
? new Date(entry.created_at).toLocaleString()
|
||||||
|
: 'Unknown date'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`font-semibold ${
|
||||||
|
entry.amount_cents > 0 ? 'text-emerald-600' :
|
||||||
|
entry.amount_cents < 0 ? 'text-red-600' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{formatCurrency(entry.amount_cents, entry.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ArrowLeft, Loader2, Wallet, Search, ChevronRight } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { listMemberCredits } from '@/src/lib/api/facility-admin';
|
||||||
|
import type { MemberCreditBalance } from '@/src/types/facility-admin';
|
||||||
|
import useTranslation from '@/src/hooks/useTranslation';
|
||||||
|
import CreditDetailModal from './CreditDetailModal';
|
||||||
|
|
||||||
|
interface CreditsManagementComponentProps {
|
||||||
|
clubId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreditsManagementComponent({ clubId }: CreditsManagementComponentProps) {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const [credits, setCredits] = useState<MemberCreditBalance[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedUser, setSelectedUser] = useState<MemberCreditBalance | null>(null);
|
||||||
|
const [showZeroBalances, setShowZeroBalances] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredits();
|
||||||
|
}, [clubId, showZeroBalances]);
|
||||||
|
|
||||||
|
async function fetchCredits() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const minBalance = showZeroBalances ? 0 : 1;
|
||||||
|
const result = await listMemberCredits(clubId, minBalance);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setCredits(result.data);
|
||||||
|
} else {
|
||||||
|
setError(result.error.detail || 'Failed to load credit balances');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(cents: number, currency: string = 'CHF'): string {
|
||||||
|
return `${currency} ${(cents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCredits = credits.filter(credit => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
credit.display_name.toLowerCase().includes(query) ||
|
||||||
|
credit.email.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBalance = credits.reduce((sum, c) => sum + c.balance_cents, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/admin/clubs/${clubId}`}
|
||||||
|
className="inline-flex items-center text-gray-600 hover:text-gray-800 font-medium mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Club
|
||||||
|
</Link>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800">Member Credits</h1>
|
||||||
|
<p className="text-gray-600 mt-1">View and manage member credit balances</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 rounded-xl p-4 text-white shadow-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Wallet className="w-5 h-5" />
|
||||||
|
<span className="text-sm font-medium opacity-90">Total Outstanding</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(totalBalance)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-75 mt-1">
|
||||||
|
{credits.length} member{credits.length !== 1 ? 's' : ''} with balance
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showZeroBalances}
|
||||||
|
onChange={e => setShowZeroBalances(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
Show zero balances
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Credits List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-600">Loading credits...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredCredits.length === 0 ? (
|
||||||
|
<div className="text-center py-16 bg-gray-50 rounded-xl">
|
||||||
|
<div className="text-6xl mb-4">
|
||||||
|
<Wallet className="w-16 h-16 mx-auto text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-700 mb-2">
|
||||||
|
{searchQuery ? 'No matching members' : 'No credit balances'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{searchQuery
|
||||||
|
? 'Try a different search term'
|
||||||
|
: 'Members with credit balances will appear here'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Member</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Balance</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-semibold text-gray-600">Updated</th>
|
||||||
|
<th className="w-12"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{filteredCredits.map(credit => (
|
||||||
|
<tr
|
||||||
|
key={credit.app_user_id}
|
||||||
|
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => setSelectedUser(credit)}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-medium text-gray-900">{credit.display_name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{credit.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
credit.balance_cents > 0 ? 'text-emerald-600' :
|
||||||
|
credit.balance_cents < 0 ? 'text-red-600' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{formatCurrency(credit.balance_cents, credit.currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right text-sm text-gray-500">
|
||||||
|
{credit.updated_at
|
||||||
|
? new Date(credit.updated_at).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Credit Detail Modal */}
|
||||||
|
{selectedUser && (
|
||||||
|
<CreditDetailModal
|
||||||
|
isOpen={!!selectedUser}
|
||||||
|
onClose={() => setSelectedUser(null)}
|
||||||
|
facilityId={clubId}
|
||||||
|
user={selectedUser}
|
||||||
|
onBalanceChange={fetchCredits}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import AdminAuthGuard from '@/src/components/AdminAuthGuard';
|
||||||
|
import CreditsManagementComponent from './CreditsManagementComponent';
|
||||||
|
|
||||||
|
export default async function CreditsManagementPage({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ club_id: string }>;
|
||||||
|
}) {
|
||||||
|
const { club_id } = await params;
|
||||||
|
const clubId = parseInt(club_id, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminAuthGuard>
|
||||||
|
<CreditsManagementComponent clubId={clubId} />
|
||||||
|
</AdminAuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,269 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ArrowLeft, Loader2, ArrowRightLeft, Calendar, Filter, RefreshCw } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { listFacilityTransfers } from '@/src/lib/api/facility-admin';
|
||||||
|
import type { AdminTransferOffer, TransferListFilters } from '@/src/types/facility-admin';
|
||||||
|
import useTranslation from '@/src/hooks/useTranslation';
|
||||||
|
|
||||||
|
interface TransfersManagementComponentProps {
|
||||||
|
clubId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferStatus = 'all' | 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled';
|
||||||
|
|
||||||
|
export default function TransfersManagementComponent({ clubId }: TransfersManagementComponentProps) {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const [transfers, setTransfers] = useState<AdminTransferOffer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<TransferStatus>('all');
|
||||||
|
const [dateRange, setDateRange] = useState<{ from: string; to: string }>({
|
||||||
|
from: '',
|
||||||
|
to: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTransfers();
|
||||||
|
}, [clubId, statusFilter, dateRange]);
|
||||||
|
|
||||||
|
async function fetchTransfers() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const filters: TransferListFilters = {};
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filters.status = statusFilter;
|
||||||
|
}
|
||||||
|
if (dateRange.from) {
|
||||||
|
filters.from_date = dateRange.from;
|
||||||
|
}
|
||||||
|
if (dateRange.to) {
|
||||||
|
filters.to_date = dateRange.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await listFacilityTransfers(clubId, filters);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setTransfers(result.data);
|
||||||
|
} else {
|
||||||
|
setError(result.error.detail || 'Failed to load transfers');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status: string) {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
|
accepted: 'bg-emerald-100 text-emerald-700',
|
||||||
|
declined: 'bg-red-100 text-red-700',
|
||||||
|
expired: 'bg-gray-100 text-gray-600',
|
||||||
|
cancelled: 'bg-gray-100 text-gray-600'
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: transfers.length,
|
||||||
|
pending: transfers.filter(t => t.status === 'pending').length,
|
||||||
|
accepted: transfers.filter(t => t.status === 'accepted').length,
|
||||||
|
expired: transfers.filter(t => t.status === 'expired').length
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/admin/clubs/${clubId}`}
|
||||||
|
className="inline-flex items-center text-gray-600 hover:text-gray-800 font-medium mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Club
|
||||||
|
</Link>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800">Booking Transfers</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Monitor booking transfer requests and their status</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchTransfers}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||||||
|
<div className="text-sm text-gray-600">Total</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||||
|
<div className="text-sm text-amber-700">Pending</div>
|
||||||
|
<div className="text-2xl font-bold text-amber-700">{stats.pending}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
|
||||||
|
<div className="text-sm text-emerald-700">Accepted</div>
|
||||||
|
<div className="text-2xl font-bold text-emerald-700">{stats.accepted}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4">
|
||||||
|
<div className="text-sm text-gray-600">Expired</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-600">{stats.expired}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-4 mb-6 bg-gray-50 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Filters:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value as TransferStatus)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="accepted">Accepted</option>
|
||||||
|
<option value="declined">Declined</option>
|
||||||
|
<option value="expired">Expired</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.from}
|
||||||
|
onChange={e => setDateRange({ ...dateRange, from: e.target.value })}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
|
||||||
|
placeholder="From"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-400">to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.to}
|
||||||
|
onChange={e => setDateRange({ ...dateRange, to: e.target.value })}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
|
||||||
|
placeholder="To"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(statusFilter !== 'all' || dateRange.from || dateRange.to) && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setDateRange({ from: '', to: '' });
|
||||||
|
}}
|
||||||
|
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfers List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-600">Loading transfers...</span>
|
||||||
|
</div>
|
||||||
|
) : transfers.length === 0 ? (
|
||||||
|
<div className="text-center py-16 bg-gray-50 rounded-xl">
|
||||||
|
<div className="text-6xl mb-4">
|
||||||
|
<ArrowRightLeft className="w-16 h-16 mx-auto text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-700 mb-2">No transfers found</h2>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{statusFilter !== 'all' || dateRange.from || dateRange.to
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'Transfer requests will appear here when members initiate them'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">From</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">To</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Booking</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Status</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Created</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-semibold text-gray-600">Expires</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{transfers.map(transfer => (
|
||||||
|
<tr key={transfer.offer_id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-medium text-gray-900">{transfer.from_user.display_name}</div>
|
||||||
|
<div className="text-sm text-gray-500">{transfer.from_user.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{transfer.to_user ? (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-gray-900">{transfer.to_user.display_name || 'Unknown'}</div>
|
||||||
|
<div className="text-sm text-gray-500">{transfer.to_user.email || '-'}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">Open offer</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-medium text-gray-900">{transfer.booking_info.court_name}</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{transfer.booking_info.sport_name}
|
||||||
|
{transfer.booking_info.starts_at && (
|
||||||
|
<> · {new Date(transfer.booking_info.starts_at).toLocaleDateString()}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{getStatusBadge(transfer.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{formatDateTime(transfer.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{transfer.status === 'pending'
|
||||||
|
? formatDateTime(transfer.expires_at)
|
||||||
|
: transfer.resolved_at
|
||||||
|
? formatDateTime(transfer.resolved_at)
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import AdminAuthGuard from '@/src/components/AdminAuthGuard';
|
||||||
|
import TransfersManagementComponent from './TransfersManagementComponent';
|
||||||
|
|
||||||
|
export default async function TransfersManagementPage({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ club_id: string }>;
|
||||||
|
}) {
|
||||||
|
const { club_id } = await params;
|
||||||
|
const clubId = parseInt(club_id, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminAuthGuard>
|
||||||
|
<TransfersManagementComponent clubId={clubId} />
|
||||||
|
</AdminAuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import Modal from '@/src/components/modals/Modal';
|
||||||
|
import ModalHeader from '@/src/components/modals/ModalHeader';
|
||||||
|
import ModalBody from '@/src/components/modals/ModalBody';
|
||||||
|
import ModalFooter from '@/src/components/modals/ModalFooter';
|
||||||
|
import PlanEntitlementsEditor from './PlanEntitlementsEditor';
|
||||||
|
import { getEntitlements, updateEntitlements } from '@/src/lib/api/facility-admin';
|
||||||
|
import type { MembershipPlan, PlanEntitlements } from '@/src/types/facility-admin';
|
||||||
|
|
||||||
|
interface EntitlementsConfigModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
facilityId: number;
|
||||||
|
plan: MembershipPlan;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntitlementsConfigModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
facilityId,
|
||||||
|
plan,
|
||||||
|
onSuccess
|
||||||
|
}: EntitlementsConfigModalProps) {
|
||||||
|
const [entitlements, setEntitlements] = useState<PlanEntitlements>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchEntitlements();
|
||||||
|
}
|
||||||
|
}, [isOpen, plan.facility_membership_plan_id]);
|
||||||
|
|
||||||
|
async function fetchEntitlements() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await getEntitlements(facilityId, plan.facility_membership_plan_id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setEntitlements(result.data);
|
||||||
|
} else {
|
||||||
|
setError(result.error.detail || 'Failed to load entitlements');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await updateEntitlements(
|
||||||
|
facilityId,
|
||||||
|
plan.facility_membership_plan_id,
|
||||||
|
entitlements
|
||||||
|
);
|
||||||
|
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error.detail || 'Failed to update entitlements');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<ModalHeader onClose={onClose}>
|
||||||
|
Configure Entitlements: {plan.name}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-600">Loading entitlements...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Basic Entitlements */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
|
Booking Limits
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Max Active Bookings
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={entitlements.max_active_bookings ?? ''}
|
||||||
|
onChange={e => setEntitlements({
|
||||||
|
...entitlements,
|
||||||
|
max_active_bookings: e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
|
placeholder="Unlimited"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Advance Booking Window (days)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={entitlements.advance_window_days ?? ''}
|
||||||
|
onChange={e => setEntitlements({
|
||||||
|
...entitlements,
|
||||||
|
advance_window_days: e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
|
placeholder="Default"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Daily Free Bookings
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={entitlements.daily_free_bookings ?? ''}
|
||||||
|
onChange={e => setEntitlements({
|
||||||
|
...entitlements,
|
||||||
|
daily_free_bookings: e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
})}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
|
placeholder="None"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center pt-6">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={entitlements.can_book ?? true}
|
||||||
|
onChange={e => setEntitlements({
|
||||||
|
...entitlements,
|
||||||
|
can_book: e.target.checked
|
||||||
|
})}
|
||||||
|
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Can make bookings</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pay-Per-Court Settings */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<PlanEntitlementsEditor
|
||||||
|
entitlements={entitlements}
|
||||||
|
onChange={setEntitlements}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium transition-colors"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || loading}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-2 rounded-lg font-semibold shadow-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
{submitting ? 'Saving...' : 'Save Entitlements'}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, DollarSign, Clock, RefreshCcw, Users } from 'lucide-react';
|
||||||
|
import type { PlanEntitlements } from '@/src/types/facility-admin';
|
||||||
|
|
||||||
|
interface PlanEntitlementsEditorProps {
|
||||||
|
entitlements: PlanEntitlements;
|
||||||
|
onChange: (entitlements: PlanEntitlements) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlanEntitlementsEditor({
|
||||||
|
entitlements,
|
||||||
|
onChange,
|
||||||
|
disabled = false
|
||||||
|
}: PlanEntitlementsEditorProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(
|
||||||
|
// Auto-expand if any pricing entitlements are set
|
||||||
|
Boolean(
|
||||||
|
entitlements.booking_threshold_hours ||
|
||||||
|
entitlements.seat_price_cents ||
|
||||||
|
entitlements.default_joiner_policy
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateEntitlement<K extends keyof PlanEntitlements>(
|
||||||
|
key: K,
|
||||||
|
value: PlanEntitlements[K]
|
||||||
|
) {
|
||||||
|
onChange({ ...entitlements, [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(cents: number | undefined): string {
|
||||||
|
if (cents === undefined || cents === null) return '';
|
||||||
|
return (cents / 100).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCurrency(value: string): number | undefined {
|
||||||
|
if (!value.trim()) return undefined;
|
||||||
|
const parsed = parseFloat(value);
|
||||||
|
return isNaN(parsed) ? undefined : Math.round(parsed * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPricingRules = Boolean(
|
||||||
|
entitlements.booking_threshold_hours && entitlements.booking_threshold_hours > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
{/* Header - Collapsible */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 flex items-center justify-between hover:bg-gray-100 transition-colors"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-gray-700">Pay-Per-Court Settings</span>
|
||||||
|
{hasPricingRules && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full font-medium">
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{isExpanded ? 'Click to collapse' : 'Click to expand'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
{/* Booking Threshold */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Clock className="w-4 h-4 text-gray-500" />
|
||||||
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Full-Court Booking Threshold (hours)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="168"
|
||||||
|
value={entitlements.booking_threshold_hours ?? ''}
|
||||||
|
onChange={e => updateEntitlement(
|
||||||
|
'booking_threshold_hours',
|
||||||
|
e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||||
|
)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:bg-gray-100"
|
||||||
|
placeholder="e.g., 48 (0 = disabled)"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Bookings made within this many hours of the match require full-court payment. Set to 0 or leave empty to disable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seat Price */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<DollarSign className="w-4 h-4 text-gray-500" />
|
||||||
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Seat Price (CHF)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-2 text-gray-500">CHF</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formatCurrency(entitlements.seat_price_cents)}
|
||||||
|
onChange={e => updateEntitlement('seat_price_cents', parseCurrency(e.target.value))}
|
||||||
|
className="w-full pl-14 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:bg-gray-100"
|
||||||
|
placeholder="e.g., 15.00"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Price per seat. Late bookings will pay 4x this amount (full court).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Refund Rules */}
|
||||||
|
<div className="space-y-3 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RefreshCcw className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Refund Policies</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={entitlements.early_booking_refund_allowed ?? true}
|
||||||
|
onChange={e => updateEntitlement('early_booking_refund_allowed', e.target.checked)}
|
||||||
|
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Early booking refunds</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={entitlements.late_booking_refund_allowed ?? false}
|
||||||
|
onChange={e => updateEntitlement('late_booking_refund_allowed', e.target.checked)}
|
||||||
|
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Late booking refunds</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={entitlements.credit_refund_allowed ?? true}
|
||||||
|
onChange={e => updateEntitlement('credit_refund_allowed', e.target.checked)}
|
||||||
|
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Credit balance refunds</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={entitlements.stripe_refund_allowed ?? true}
|
||||||
|
onChange={e => updateEntitlement('stripe_refund_allowed', e.target.checked)}
|
||||||
|
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Stripe payment refunds</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Joiner Policy */}
|
||||||
|
<div className="space-y-2 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Users className="w-4 h-4 text-gray-500" />
|
||||||
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Default Joiner Policy
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={entitlements.default_joiner_policy ?? 'joiner_pays'}
|
||||||
|
onChange={e => updateEntitlement(
|
||||||
|
'default_joiner_policy',
|
||||||
|
e.target.value as 'joiner_pays' | 'booker_absorbs'
|
||||||
|
)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:bg-gray-100"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<option value="joiner_pays">Joiner pays (refund to booker)</option>
|
||||||
|
<option value="booker_absorbs">Booker absorbs (joiners free)</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
When someone joins a booking, should they pay for their seat (refunding the booker), or join for free?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue