From 7055eb2f43c426357c68d11d4a3ec56e3a87da21 Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Thu, 27 Nov 2025 13:43:29 +0100 Subject: [PATCH] feat: add admin UI for credits, transfers, and plan entitlements - 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 --- .../admin/clubs/[club_id]/ClubDetailTabs.tsx | 12 + .../[club_id]/credits/CreditDetailModal.tsx | 304 ++++++++++++++++++ .../credits/CreditsManagementComponent.tsx | 197 ++++++++++++ .../admin/clubs/[club_id]/credits/page.tsx | 17 + .../plans/MembershipPlansComponent.tsx | 43 ++- .../TransfersManagementComponent.tsx | 269 ++++++++++++++++ .../admin/clubs/[club_id]/transfers/page.tsx | 17 + .../plans/EntitlementsConfigModal.tsx | 207 ++++++++++++ src/components/plans/PlanCard.tsx | 60 +++- .../plans/PlanEntitlementsEditor.tsx | 213 ++++++++++++ src/lib/api/facility-admin.ts | 173 ++++++++++ src/types/facility-admin.ts | 91 ++++++ 12 files changed, 1588 insertions(+), 15 deletions(-) create mode 100644 src/app/[locale]/admin/clubs/[club_id]/credits/CreditDetailModal.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/credits/CreditsManagementComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/credits/page.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/transfers/TransfersManagementComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/transfers/page.tsx create mode 100644 src/components/plans/EntitlementsConfigModal.tsx create mode 100644 src/components/plans/PlanEntitlementsEditor.tsx diff --git a/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx b/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx index c151482..f4b55ac 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx @@ -214,6 +214,18 @@ export default function ClubDetailTabs({ clubId }: ClubDetailTabsProps) { > Settings + + Credits + + + Transfers + diff --git a/src/app/[locale]/admin/clubs/[club_id]/credits/CreditDetailModal.tsx b/src/app/[locale]/admin/clubs/[club_id]/credits/CreditDetailModal.tsx new file mode 100644 index 0000000..199e6a0 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/credits/CreditDetailModal.tsx @@ -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([]); + const [balance, setBalance] = useState(user.balance_cents); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 = { + '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 ( + + +
+
{user.display_name}
+
{user.email}
+
+
+ + + {error && ( +
+ + {error} +
+ )} + + {/* Current Balance */} +
+
Current Balance
+
0 ? 'text-emerald-600' : + balance < 0 ? 'text-red-600' : 'text-gray-600' + }`}> + CHF {(balance / 100).toFixed(2)} +
+
+ + {/* Adjustment Form */} + {showAdjustForm ? ( +
+

Adjust Balance

+ +
+ + +
+ +
+ + 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} + /> +
+ +
+ + 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} + /> +
+ +
+ + +
+
+ ) : ( + + )} + + {/* Ledger History */} +
+
+ +

Transaction History

+
+ + {loading ? ( +
+ + Loading history... +
+ ) : ledgerEntries.length === 0 ? ( +
+

No transactions yet

+
+ ) : ( +
+ {ledgerEntries.map(entry => ( +
+
+
+ {formatEntryType(entry.entry_type)} +
+ {entry.description && ( +
{entry.description}
+ )} +
+ {entry.created_at + ? new Date(entry.created_at).toLocaleString() + : 'Unknown date'} +
+
+
0 ? 'text-emerald-600' : + entry.amount_cents < 0 ? 'text-red-600' : 'text-gray-500' + }`}> + {formatCurrency(entry.amount_cents, entry.currency)} +
+
+ ))} +
+ )} +
+
+ + + + +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/credits/CreditsManagementComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/credits/CreditsManagementComponent.tsx new file mode 100644 index 0000000..ef58239 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/credits/CreditsManagementComponent.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedUser, setSelectedUser] = useState(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 ( +
+ {/* Header */} +
+ + + Back to Club + +
+
+

Member Credits

+

View and manage member credit balances

+
+
+
+ + Total Outstanding +
+
+ {formatCurrency(totalBalance)} +
+
+ {credits.length} member{credits.length !== 1 ? 's' : ''} with balance +
+
+
+
+ + {/* Filters */} +
+
+ + 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" + /> +
+ +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Credits List */} + {loading ? ( +
+ + Loading credits... +
+ ) : filteredCredits.length === 0 ? ( +
+
+ +
+

+ {searchQuery ? 'No matching members' : 'No credit balances'} +

+

+ {searchQuery + ? 'Try a different search term' + : 'Members with credit balances will appear here'} +

+
+ ) : ( +
+ + + + + + + + + + + {filteredCredits.map(credit => ( + setSelectedUser(credit)} + > + + + + + + ))} + +
MemberBalanceUpdated
+
{credit.display_name}
+
{credit.email}
+
+ 0 ? 'text-emerald-600' : + credit.balance_cents < 0 ? 'text-red-600' : 'text-gray-500' + }`}> + {formatCurrency(credit.balance_cents, credit.currency)} + + + {credit.updated_at + ? new Date(credit.updated_at).toLocaleDateString() + : '-'} + + +
+
+ )} + + {/* Credit Detail Modal */} + {selectedUser && ( + setSelectedUser(null)} + facilityId={clubId} + user={selectedUser} + onBalanceChange={fetchCredits} + /> + )} +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/credits/page.tsx b/src/app/[locale]/admin/clubs/[club_id]/credits/page.tsx new file mode 100644 index 0000000..1c004eb --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/credits/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/plans/MembershipPlansComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/plans/MembershipPlansComponent.tsx index 38e32b3..60472b4 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/plans/MembershipPlansComponent.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/plans/MembershipPlansComponent.tsx @@ -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([]); + const [entitlementsMap, setEntitlementsMap] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [templatePickerOpen, setTemplatePickerOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [selectedTemplate, setSelectedTemplate] = useState(null); const [editingPlan, setEditingPlan] = useState(null); + const [configuringPlan, setConfiguringPlan] = useState(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 = {}; + + 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 setEditingPlan(plan)} onDelete={() => handleDelete(plan.facility_membership_plan_id, plan.name)} + onConfigureEntitlements={() => setConfiguringPlan(plan)} /> ))} @@ -154,6 +182,17 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp onSuccess={fetchPlans} /> )} + + {/* Entitlements Config Modal */} + {configuringPlan && ( + setConfiguringPlan(null)} + facilityId={clubId} + plan={configuringPlan} + onSuccess={fetchPlans} + /> + )} ); } diff --git a/src/app/[locale]/admin/clubs/[club_id]/transfers/TransfersManagementComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/transfers/TransfersManagementComponent.tsx new file mode 100644 index 0000000..0562c9f --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/transfers/TransfersManagementComponent.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState('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 = { + 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 ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + } + + 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 ( +
+ {/* Header */} +
+ + + Back to Club + +
+
+

Booking Transfers

+

Monitor booking transfer requests and their status

+
+ +
+
+ + {/* Stats */} +
+
+
Total
+
{stats.total}
+
+
+
Pending
+
{stats.pending}
+
+
+
Accepted
+
{stats.accepted}
+
+
+
Expired
+
{stats.expired}
+
+
+ + {/* Filters */} +
+
+ + Filters: +
+ + + +
+ + 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" + /> + to + 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" + /> +
+ + {(statusFilter !== 'all' || dateRange.from || dateRange.to) && ( + + )} +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Transfers List */} + {loading ? ( +
+ + Loading transfers... +
+ ) : transfers.length === 0 ? ( +
+
+ +
+

No transfers found

+

+ {statusFilter !== 'all' || dateRange.from || dateRange.to + ? 'Try adjusting your filters' + : 'Transfer requests will appear here when members initiate them'} +

+
+ ) : ( +
+
+ + + + + + + + + + + + + {transfers.map(transfer => ( + + + + + + + + + ))} + +
FromToBookingStatusCreatedExpires
+
{transfer.from_user.display_name}
+
{transfer.from_user.email}
+
+ {transfer.to_user ? ( + <> +
{transfer.to_user.display_name || 'Unknown'}
+
{transfer.to_user.email || '-'}
+ + ) : ( + Open offer + )} +
+
{transfer.booking_info.court_name}
+
+ {transfer.booking_info.sport_name} + {transfer.booking_info.starts_at && ( + <> · {new Date(transfer.booking_info.starts_at).toLocaleDateString()} + )} +
+
+ {getStatusBadge(transfer.status)} + + {formatDateTime(transfer.created_at)} + + {transfer.status === 'pending' + ? formatDateTime(transfer.expires_at) + : transfer.resolved_at + ? formatDateTime(transfer.resolved_at) + : '-'} +
+
+
+ )} +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/transfers/page.tsx b/src/app/[locale]/admin/clubs/[club_id]/transfers/page.tsx new file mode 100644 index 0000000..daeffd6 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/transfers/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/components/plans/EntitlementsConfigModal.tsx b/src/components/plans/EntitlementsConfigModal.tsx new file mode 100644 index 0000000..9d02759 --- /dev/null +++ b/src/components/plans/EntitlementsConfigModal.tsx @@ -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({}); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + +
+ + Configure Entitlements: {plan.name} + + + + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ + Loading entitlements... +
+ ) : ( +
+ {/* Basic Entitlements */} +
+

+ Booking Limits +

+ +
+
+ + 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} + /> +
+ +
+ + 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} + /> +
+ +
+ + 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} + /> +
+ +
+ +
+
+
+ + {/* Pay-Per-Court Settings */} +
+ +
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/plans/PlanCard.tsx b/src/components/plans/PlanCard.tsx index 0091fbc..5fcceb0 100644 --- a/src/components/plans/PlanCard.tsx +++ b/src/components/plans/PlanCard.tsx @@ -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 (

{plan.name}

- {!plan.is_active && ( - - Inactive - - )} +
+ {hasPayPerCourt && ( + + Pay-Per-Court + + )} + {!plan.is_active && ( + + Inactive + + )} +
{/* Price */} @@ -48,14 +68,19 @@ export default function PlanCard({ plan, onEdit, onDelete }: PlanCardProps) { - {/* Sport Badge */} - {plan.sport_id && ( -
+ {/* Badges */} +
+ {plan.sport_id && ( - 🎾 Sport-specific + Sport-specific -
- )} + )} + {entitlements?.seat_price_cents && ( + + CHF {formatPrice(entitlements.seat_price_cents)}/seat + + )} +
{/* Actions */}
@@ -65,6 +90,15 @@ export default function PlanCard({ plan, onEdit, onDelete }: PlanCardProps) { > Edit + {onConfigureEntitlements && ( + + )} + + {/* Content */} + {isExpanded && ( +
+ {/* Booking Threshold */} +
+
+ + +
+ 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} + /> +

+ Bookings made within this many hours of the match require full-court payment. Set to 0 or leave empty to disable. +

+
+ + {/* Seat Price */} +
+
+ + +
+
+ CHF + 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} + /> +
+

+ Price per seat. Late bookings will pay 4x this amount (full court). +

+
+ + {/* Refund Rules */} +
+
+ + Refund Policies +
+ +
+ + + + + + + +
+
+ + {/* Joiner Policy */} +
+
+ + +
+ +

+ When someone joins a booking, should they pay for their seat (refunding the booker), or join for free? +

+
+
+ )} +
+ ); +} diff --git a/src/lib/api/facility-admin.ts b/src/lib/api/facility-admin.ts index 61403ff..6e0e05f 100644 --- a/src/lib/api/facility-admin.ts +++ b/src/lib/api/facility-admin.ts @@ -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> { + 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; + } 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> { + 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(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> { + 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(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> { + 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; + } 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', + }, + }; + } +} diff --git a/src/types/facility-admin.ts b/src/types/facility-admin.ts index 2a68967..ffafce4 100644 --- a/src/types/facility-admin.ts +++ b/src/types/facility-admin.ts @@ -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