feat: add admin UI for credits, transfers, and plan entitlements
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 interfaces
master
Guillermo Pages 2 months ago
parent 9f059bcbfe
commit 7055eb2f43

@ -214,6 +214,18 @@ export default function ClubDetailTabs({ clubId }: ClubDetailTabsProps) {
>
Settings
</Link>
<Link
href={`/${locale}/admin/clubs/${clubId}/credits`}
className="px-6 py-3 font-semibold text-emerald-600 hover:text-emerald-700 hover:border-emerald-300 transition-colors border-b-2 border-transparent"
>
Credits
</Link>
<Link
href={`/${locale}/admin/clubs/${clubId}/transfers`}
className="px-6 py-3 font-semibold text-amber-600 hover:text-amber-700 hover:border-amber-300 transition-colors border-b-2 border-transparent"
>
Transfers
</Link>
</div>
</div>

@ -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>
);
}

@ -1,12 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
import { listPlans, deletePlan } from '@/src/lib/api/facility-admin';
import type { MembershipPlan, PlanTemplate } from '@/src/types/facility-admin';
import { listPlans, deletePlan, getEntitlements } from '@/src/lib/api/facility-admin';
import type { MembershipPlan, PlanTemplate, PlanEntitlements } from '@/src/types/facility-admin';
import PlanCard from '@/src/components/plans/PlanCard';
import PlanFormModal from '@/src/components/plans/PlanFormModal';
import PlanListSkeleton from '@/src/components/plans/PlanListSkeleton';
import TemplatePicker from '@/src/components/plans/TemplatePicker';
import EntitlementsConfigModal from '@/src/components/plans/EntitlementsConfigModal';
interface MembershipPlansComponentProps {
clubId: number;
@ -14,12 +15,14 @@ interface MembershipPlansComponentProps {
export default function MembershipPlansComponent({ clubId }: MembershipPlansComponentProps) {
const [plans, setPlans] = useState<MembershipPlan[]>([]);
const [entitlementsMap, setEntitlementsMap] = useState<Record<number, PlanEntitlements>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<PlanTemplate | null>(null);
const [editingPlan, setEditingPlan] = useState<MembershipPlan | null>(null);
const [configuringPlan, setConfiguringPlan] = useState<MembershipPlan | null>(null);
useEffect(() => {
fetchPlans();
@ -33,6 +36,8 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
if (result.success) {
setPlans(result.data);
// Fetch entitlements for all plans in parallel
await fetchAllEntitlements(result.data);
} else {
setError(result.error.detail || 'Failed to load plans');
}
@ -40,6 +45,27 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
setLoading(false);
}
async function fetchAllEntitlements(plansList: MembershipPlan[]) {
const entitlementPromises = plansList.map(async (plan) => {
const result = await getEntitlements(clubId, plan.facility_membership_plan_id);
if (result.success) {
return { planId: plan.facility_membership_plan_id, entitlements: result.data };
}
return null;
});
const results = await Promise.all(entitlementPromises);
const newEntitlementsMap: Record<number, PlanEntitlements> = {};
for (const result of results) {
if (result) {
newEntitlementsMap[result.planId] = result.entitlements;
}
}
setEntitlementsMap(newEntitlementsMap);
}
async function handleDelete(planId: number, planName: string) {
if (!confirm(`Are you sure you want to deactivate "${planName}"?`)) return;
@ -112,8 +138,10 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
<PlanCard
key={plan.facility_membership_plan_id}
plan={plan}
entitlements={entitlementsMap[plan.facility_membership_plan_id]}
onEdit={() => setEditingPlan(plan)}
onDelete={() => handleDelete(plan.facility_membership_plan_id, plan.name)}
onConfigureEntitlements={() => setConfiguringPlan(plan)}
/>
))}
</div>
@ -154,6 +182,17 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
onSuccess={fetchPlans}
/>
)}
{/* Entitlements Config Modal */}
{configuringPlan && (
<EntitlementsConfigModal
isOpen={!!configuringPlan}
onClose={() => setConfiguringPlan(null)}
facilityId={clubId}
plan={configuringPlan}
onSuccess={fetchPlans}
/>
)}
</div>
);
}

@ -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 && (
<> &middot; {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>
);
}

@ -1,14 +1,23 @@
'use client';
import type { MembershipPlan } from '@/src/types/facility-admin';
import { Settings } from 'lucide-react';
import type { MembershipPlan, PlanEntitlements } from '@/src/types/facility-admin';
interface PlanCardProps {
plan: MembershipPlan;
entitlements?: PlanEntitlements;
onEdit: () => void;
onDelete: () => void;
onConfigureEntitlements?: () => void;
}
export default function PlanCard({ plan, onEdit, onDelete }: PlanCardProps) {
export default function PlanCard({
plan,
entitlements,
onEdit,
onDelete,
onConfigureEntitlements
}: PlanCardProps) {
function formatPrice(cents: number): string {
return (cents / 100).toFixed(2);
}
@ -22,6 +31,10 @@ export default function PlanCard({ plan, onEdit, onDelete }: PlanCardProps) {
return periodMap[period] || period;
}
const hasPayPerCourt = entitlements &&
entitlements.booking_threshold_hours &&
entitlements.booking_threshold_hours > 0;
return (
<div className={`bg-white border-2 rounded-xl p-6 shadow-sm hover:shadow-md transition-all duration-200 ${
!plan.is_active ? 'border-gray-300 opacity-60' : 'border-purple-200'
@ -29,11 +42,18 @@ export default function PlanCard({ plan, onEdit, onDelete }: PlanCardProps) {
{/* Header */}
<div className="flex justify-between items-start mb-4">
<h3 className="text-xl font-bold text-gray-800">{plan.name}</h3>
{!plan.is_active && (
<span className="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded-full font-medium">
Inactive
</span>
)}
<div className="flex items-center gap-2">
{hasPayPerCourt && (
<span className="text-xs bg-emerald-100 text-emerald-700 px-2 py-1 rounded-full font-medium">
Pay-Per-Court
</span>
)}
{!plan.is_active && (
<span className="text-xs bg-gray-200 text-gray-600 px-2 py-1 rounded-full font-medium">
Inactive
</span>
)}
</div>
</div>
{/* Price */}
@ -48,14 +68,19 @@ export default function PlanCard({ plan, onEdit, onDelete }: PlanCardProps) {
</div>
</div>
{/* Sport Badge */}
{plan.sport_id && (
<div className="mb-4">
{/* Badges */}
<div className="flex flex-wrap gap-2 mb-4">
{plan.sport_id && (
<span className="inline-flex items-center text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full font-medium">
🎾 Sport-specific
Sport-specific
</span>
</div>
)}
)}
{entitlements?.seat_price_cents && (
<span className="inline-flex items-center text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full font-medium">
CHF {formatPrice(entitlements.seat_price_cents)}/seat
</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-4 border-t border-gray-200">
@ -65,6 +90,15 @@ export default function PlanCard({ plan, onEdit, onDelete }: PlanCardProps) {
>
Edit
</button>
{onConfigureEntitlements && (
<button
onClick={onConfigureEntitlements}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
title="Configure Entitlements"
>
<Settings className="w-5 h-5" />
</button>
)}
<button
onClick={onDelete}
className="flex-1 px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 font-medium transition-colors"

@ -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>
);
}

@ -637,3 +637,176 @@ export async function updatePolicy(
};
}
}
// ============================================================================
// Member Credits
// ============================================================================
import type {
MemberCreditBalance,
UserCreditDetail,
CreditAdjustmentRequest,
CreditAdjustmentResult,
AdminTransferOffer,
TransferListFilters,
} from '@/src/types/facility-admin';
/**
* GET /admin/facilities/{facility_id}/credits
* List all users with credit balances at this facility
*/
export async function listMemberCredits(
facilityId: number,
minBalance: number = 1
): Promise<FacilityAdminApiResult<MemberCreditBalance[]>> {
try {
const params = new URLSearchParams();
if (minBalance !== 1) params.set('min_balance', String(minBalance));
const queryString = params.toString();
const endpoint = `/admin/facilities/${facilityId}/credits${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ credits: MemberCreditBalance[] }>(response);
if (result.success) {
return { success: true, data: result.data.credits };
}
return result as FacilityAdminApiResult<MemberCreditBalance[]>;
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to list member credits',
code: 'internal_error',
},
};
}
}
/**
* GET /admin/facilities/{facility_id}/users/{app_user_id}/credit
* Get a specific user's credit balance and history at facility
*/
export async function getUserCreditDetail(
facilityId: number,
appUserId: number,
limit: number = 50,
offset: number = 0
): Promise<FacilityAdminApiResult<UserCreditDetail>> {
try {
const params = new URLSearchParams();
if (limit !== 50) params.set('limit', String(limit));
if (offset !== 0) params.set('offset', String(offset));
const queryString = params.toString();
const endpoint = `/admin/facilities/${facilityId}/users/${appUserId}/credit${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<UserCreditDetail>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to get user credit detail',
code: 'internal_error',
},
};
}
}
/**
* POST /admin/facilities/{facility_id}/users/{app_user_id}/credit/adjust
* Manually adjust a user's credit balance
*/
export async function adjustUserCredit(
facilityId: number,
appUserId: number,
request: CreditAdjustmentRequest
): Promise<FacilityAdminApiResult<CreditAdjustmentResult>> {
try {
const response = await apiFetch(
`/admin/facilities/${facilityId}/users/${appUserId}/credit/adjust`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}
);
return handleApiResponse<CreditAdjustmentResult>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to adjust user credit',
code: 'internal_error',
},
};
}
}
// ============================================================================
// Booking Transfers
// ============================================================================
/**
* GET /admin/facilities/{facility_id}/transfers
* List booking transfer offers at this facility
*/
export async function listFacilityTransfers(
facilityId: number,
filters?: TransferListFilters,
limit: number = 50,
offset: number = 0
): Promise<FacilityAdminApiResult<AdminTransferOffer[]>> {
try {
const params = new URLSearchParams();
if (filters?.status) params.set('status', filters.status);
if (filters?.from_date) params.set('from_date', filters.from_date);
if (filters?.to_date) params.set('to_date', filters.to_date);
if (limit !== 50) params.set('limit', String(limit));
if (offset !== 0) params.set('offset', String(offset));
const queryString = params.toString();
const endpoint = `/admin/facilities/${facilityId}/transfers${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ transfers: AdminTransferOffer[] }>(response);
if (result.success) {
return { success: true, data: result.data.transfers };
}
return result as FacilityAdminApiResult<AdminTransferOffer[]>;
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to list facility transfers',
code: 'internal_error',
},
};
}
}

@ -61,6 +61,7 @@ export interface PlanTemplate {
// ============================================================================
export interface PlanEntitlements {
// Booking limits
advance_window_days?: number;
max_active_bookings?: number;
can_book?: boolean;
@ -68,6 +69,15 @@ export interface PlanEntitlements {
price_group?: string;
daily_free_bookings?: number;
over_quota_price_cents?: number;
// Time-based pricing entitlements
booking_threshold_hours?: number; // Hours before match that triggers full-court requirement
seat_price_cents?: number; // Price per seat in cents
early_booking_refund_allowed?: boolean;
late_booking_refund_allowed?: boolean;
credit_refund_allowed?: boolean;
stripe_refund_allowed?: boolean;
default_joiner_policy?: 'joiner_pays' | 'booker_absorbs';
}
export interface SetEntitlementRequest {
@ -240,3 +250,84 @@ export const ERROR_MESSAGES: Record<FacilityAdminErrorCode, { title: string; hin
hint: 'The selected user does not exist.',
},
};
// ============================================================================
// Member Credits (Admin View)
// ============================================================================
export interface MemberCreditBalance {
app_user_id: number;
display_name: string;
email: string;
balance_cents: number;
currency: string;
updated_at: string | null;
}
export interface CreditLedgerEntry {
ledger_entry_id: number;
amount_cents: number;
currency: string;
entry_type: string;
description: string | null;
booking_id: number | null;
capability_charge_id: number | null;
consensus_request_id: number | null;
stripe_refund_id: string | null;
created_at: string | null;
}
export interface UserCreditDetail {
facility_id: number;
app_user_id: number;
balance_cents: number;
currency: string;
ledger_entries: CreditLedgerEntry[];
}
export interface CreditAdjustmentRequest {
amount_cents: number;
reason: string;
}
export interface CreditAdjustmentResult {
app_user_id: number;
facility_id: number;
adjustment_cents: number;
new_balance_cents: number;
currency: string;
}
// ============================================================================
// Booking Transfers (Admin View)
// ============================================================================
export interface AdminTransferOffer {
offer_id: number;
booking_id: number;
from_user: {
app_user_id: number;
display_name: string;
email: string;
};
to_user: {
app_user_id: number;
display_name: string | null;
email: string | null;
} | null;
status: 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled';
expires_at: string | null;
created_at: string | null;
resolved_at: string | null;
booking_info: {
court_name: string;
starts_at: string | null;
sport_name: string;
};
}
export interface TransferListFilters {
status?: string;
from_date?: string;
to_date?: string;
}

Loading…
Cancel
Save