feat: add facility admin management UI (plans, members, policy)
continuous-integration/drone/push Build is passing Details

Implements Phase B1-B4 of the admin portal frontend:

Phase B1 - Setup & Types:
- Add TypeScript type definitions for all admin APIs
- Add complete API client with all 15 endpoint integrations

Phase B2 - Membership Plans UI:
- Add plans listing page with create/edit/delete functionality
- Add PlanCard, PlanFormModal, and PlanListSkeleton components
- Support billing periods, pricing, and activation status

Phase B3 - Member Management UI:
- Add members listing page with search and filters
- Add member CRUD modals with role/plan assignment
- Add RoleBadge, StatusBadge, and MemberCard components
- Support filtering by role and status

Phase B4 - Policy Configuration UI:
- Add facility settings page for policy management
- Add AccessModelSelector for membership/payg/open models
- Add GuestPricingInput and BookingLimitsForm components
- Support configuring booking limits and access control

All components follow existing design patterns with purple/indigo
gradients, responsive layouts, and loading states.
master
Guillermo Pages 3 weeks ago
parent ff50bdfa2c
commit 5dd283525b

@ -0,0 +1,231 @@
'use client';
import { useState, useEffect } from 'react';
import { listMembers, deleteMember } from '@/src/lib/api/facility-admin';
import type { FacilityMember, MemberRole, MemberStatus } from '@/src/types/facility-admin';
import MemberCard from '@/src/components/members/MemberCard';
import AddMemberModal from '@/src/components/members/AddMemberModal';
import EditMemberModal from '@/src/components/members/EditMemberModal';
import MemberListSkeleton from '@/src/components/members/MemberListSkeleton';
interface MembersManagementComponentProps {
clubId: number;
}
export default function MembersManagementComponent({ clubId }: MembersManagementComponentProps) {
const [members, setMembers] = useState<FacilityMember[]>([]);
const [filteredMembers, setFilteredMembers] = useState<FacilityMember[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [addModalOpen, setAddModalOpen] = useState(false);
const [editingMember, setEditingMember] = useState<FacilityMember | null>(null);
// Filters
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState<MemberRole | ''>('');
const [statusFilter, setStatusFilter] = useState<MemberStatus | ''>('active');
useEffect(() => {
fetchMembers();
}, [clubId]);
useEffect(() => {
applyFilters();
}, [members, searchQuery, roleFilter, statusFilter]);
async function fetchMembers() {
setLoading(true);
setError(null);
const result = await listMembers(clubId);
if (result.success) {
setMembers(result.data);
} else {
setError(result.error.detail || 'Failed to load members');
}
setLoading(false);
}
function applyFilters() {
let filtered = [...members];
// Search filter (name or email)
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
m =>
m.display_name.toLowerCase().includes(query) ||
m.email.toLowerCase().includes(query)
);
}
// Role filter
if (roleFilter) {
filtered = filtered.filter(m => m.role === roleFilter);
}
// Status filter
if (statusFilter) {
filtered = filtered.filter(m => m.status === statusFilter);
}
setFilteredMembers(filtered);
}
async function handleDelete(memberId: number, memberName: string) {
if (!confirm(`Are you sure you want to remove "${memberName}" from this facility?`)) return;
const result = await deleteMember(clubId, memberId);
if (result.success) {
fetchMembers();
} else {
alert(result.error.detail || 'Failed to remove member');
}
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-800">Members</h1>
<p className="text-gray-600 mt-1">Manage facility members and their access</p>
</div>
<button
onClick={() => setAddModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md transition-all duration-200"
>
+ Add Member
</button>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Filters */}
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div className="md:col-span-2">
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<input
type="text"
id="search"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search by name or email..."
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"
/>
</div>
{/* Role Filter */}
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<select
id="role"
value={roleFilter}
onChange={e => setRoleFilter(e.target.value as MemberRole | '')}
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"
>
<option value="">All Roles</option>
<option value="guest">Guest</option>
<option value="member">Member</option>
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
</div>
{/* Status Filter */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
id="status"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value as MemberStatus | '')}
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"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="trial">Trial</option>
<option value="grace">Grace Period</option>
<option value="past_due">Past Due</option>
<option value="expired">Expired</option>
<option value="cancelled">Cancelled</option>
<option value="paused">Paused</option>
</select>
</div>
</div>
</div>
{/* Members List */}
{loading ? (
<MemberListSkeleton count={5} />
) : filteredMembers.length === 0 ? (
<div className="text-center py-16">
<div className="text-6xl mb-4">👥</div>
<h2 className="text-2xl font-semibold text-gray-700 mb-2">
{searchQuery || roleFilter ? 'No members found' : 'No members yet'}
</h2>
<p className="text-gray-500 mb-6">
{searchQuery || roleFilter
? 'Try adjusting your filters'
: 'Add your first member to get started'}
</p>
{!searchQuery && !roleFilter && (
<button
onClick={() => setAddModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md transition-all duration-200"
>
+ Add Your First Member
</button>
)}
</div>
) : (
<div className="space-y-4">
{filteredMembers.map(member => (
<MemberCard
key={member.facility_member_id}
member={member}
onEdit={() => setEditingMember(member)}
onDelete={() => handleDelete(member.facility_member_id, member.display_name)}
/>
))}
</div>
)}
{/* Add Modal */}
{addModalOpen && (
<AddMemberModal
isOpen={addModalOpen}
onClose={() => setAddModalOpen(false)}
facilityId={clubId}
onSuccess={fetchMembers}
/>
)}
{/* Edit Modal */}
{editingMember && (
<EditMemberModal
isOpen={!!editingMember}
onClose={() => setEditingMember(null)}
facilityId={clubId}
member={editingMember}
onSuccess={fetchMembers}
/>
)}
</div>
);
}

@ -0,0 +1,17 @@
import AdminAuthGuard from '@/src/components/AdminAuthGuard';
import MembersManagementComponent from './MembersManagementComponent';
export default async function MembersManagementPage({
params
}: {
params: Promise<{ club_id: string }>;
}) {
const { club_id } = await params;
const clubId = parseInt(club_id, 10);
return (
<AdminAuthGuard>
<MembersManagementComponent clubId={clubId} />
</AdminAuthGuard>
);
}

@ -0,0 +1,125 @@
'use client';
import { useState, useEffect } from 'react';
import { listPlans, deletePlan } from '@/src/lib/api/facility-admin';
import type { MembershipPlan } 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';
interface MembershipPlansComponentProps {
clubId: number;
}
export default function MembershipPlansComponent({ clubId }: MembershipPlansComponentProps) {
const [plans, setPlans] = useState<MembershipPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editingPlan, setEditingPlan] = useState<MembershipPlan | null>(null);
useEffect(() => {
fetchPlans();
}, [clubId]);
async function fetchPlans() {
setLoading(true);
setError(null);
const result = await listPlans(clubId, { include_inactive: false });
if (result.success) {
setPlans(result.data);
} else {
setError(result.error.detail || 'Failed to load plans');
}
setLoading(false);
}
async function handleDelete(planId: number, planName: string) {
if (!confirm(`Are you sure you want to deactivate "${planName}"?`)) return;
const result = await deletePlan(clubId, planId);
if (result.success) {
fetchPlans();
} else {
alert(result.error.detail || 'Failed to delete plan');
}
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-800">Membership Plans</h1>
<p className="text-gray-600 mt-1">Create and manage subscription plans for your facility</p>
</div>
<button
onClick={() => setCreateModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md transition-all duration-200"
>
+ Create Plan
</button>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Plans Grid */}
{loading ? (
<PlanListSkeleton count={3} />
) : plans.length === 0 ? (
<div className="text-center py-16">
<div className="text-6xl mb-4">📋</div>
<h2 className="text-2xl font-semibold text-gray-700 mb-2">No membership plans yet</h2>
<p className="text-gray-500 mb-6">Create your first plan to get started</p>
<button
onClick={() => setCreateModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md transition-all duration-200"
>
+ Create Your First Plan
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map(plan => (
<PlanCard
key={plan.facility_membership_plan_id}
plan={plan}
onEdit={() => setEditingPlan(plan)}
onDelete={() => handleDelete(plan.facility_membership_plan_id, plan.name)}
/>
))}
</div>
)}
{/* Create Modal */}
{createModalOpen && (
<PlanFormModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
facilityId={clubId}
onSuccess={fetchPlans}
/>
)}
{/* Edit Modal */}
{editingPlan && (
<PlanFormModal
isOpen={!!editingPlan}
onClose={() => setEditingPlan(null)}
facilityId={clubId}
plan={editingPlan}
onSuccess={fetchPlans}
/>
)}
</div>
);
}

@ -0,0 +1,17 @@
import AdminAuthGuard from '@/src/components/AdminAuthGuard';
import MembershipPlansComponent from './MembershipPlansComponent';
export default async function MembershipPlansPage({
params
}: {
params: Promise<{ club_id: string }>;
}) {
const { club_id } = await params;
const clubId = parseInt(club_id, 10);
return (
<AdminAuthGuard>
<MembershipPlansComponent clubId={clubId} />
</AdminAuthGuard>
);
}

@ -0,0 +1,160 @@
'use client';
import { useState, useEffect } from 'react';
import { getPolicy, updatePolicy } from '@/src/lib/api/facility-admin';
import type { FacilityPolicy, AccessModel } from '@/src/types/facility-admin';
import AccessModelSelector from '@/src/components/policy/AccessModelSelector';
import BookingLimitsForm from '@/src/components/policy/BookingLimitsForm';
import GuestPricingInput from '@/src/components/policy/GuestPricingInput';
interface FacilitySettingsComponentProps {
clubId: number;
}
export default function FacilitySettingsComponent({ clubId }: FacilitySettingsComponentProps) {
const [policy, setPolicy] = useState<FacilityPolicy | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
fetchPolicy();
}, [clubId]);
async function fetchPolicy() {
setLoading(true);
setError(null);
const result = await getPolicy(clubId);
if (result.success) {
setPolicy(result.data);
} else {
setError(result.error.detail || 'Failed to load policy');
}
setLoading(false);
}
async function handleSave() {
if (!policy) return;
setSaving(true);
setError(null);
setSuccessMessage(null);
const result = await updatePolicy(clubId, {
access_model: policy.access_model,
guest_price_cents: policy.guest_price_cents,
default_price_group: policy.default_price_group,
require_verified_email: policy.require_verified_email,
max_future_booking_days: policy.max_future_booking_days,
max_active_bookings: policy.max_active_bookings
});
setSaving(false);
if (result.success) {
setPolicy(result.data);
setSuccessMessage('Policy updated successfully!');
setTimeout(() => setSuccessMessage(null), 3000);
} else {
setError(result.error.detail || 'Failed to update policy');
}
}
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="space-y-4">
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
);
}
if (!policy) {
return (
<div className="container mx-auto px-4 py-8">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
Failed to load facility policy
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800">Facility Settings</h1>
<p className="text-gray-600 mt-1">Configure access model, pricing, and booking policies</p>
</div>
{/* Success Message */}
{successMessage && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">
{successMessage}
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Settings Form */}
<div className="space-y-6">
{/* Access Model Section */}
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-bold text-gray-800 mb-4">Access Model</h2>
<AccessModelSelector
value={policy.access_model}
onChange={(value: AccessModel) => setPolicy({ ...policy, access_model: value })}
/>
</div>
{/* Guest Pricing Section (conditional) */}
{policy.access_model === 'payg_allowed' && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-bold text-gray-800 mb-4">Guest Pricing</h2>
<GuestPricingInput
value={policy.guest_price_cents}
onChange={(value: number) => setPolicy({ ...policy, guest_price_cents: value })}
/>
</div>
)}
{/* Booking Limits Section */}
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h2 className="text-xl font-bold text-gray-800 mb-4">Booking Limits</h2>
<BookingLimitsForm
maxFutureBookingDays={policy.max_future_booking_days}
maxActiveBookings={policy.max_active_bookings}
requireVerifiedEmail={policy.require_verified_email}
onChange={(updates) => setPolicy({ ...policy, ...updates })}
/>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
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-8 py-3 rounded-lg font-semibold shadow-md transition-all duration-200"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
);
}

@ -0,0 +1,17 @@
import AdminAuthGuard from '@/src/components/AdminAuthGuard';
import FacilitySettingsComponent from './FacilitySettingsComponent';
export default async function FacilitySettingsPage({
params
}: {
params: Promise<{ club_id: string }>;
}) {
const { club_id } = await params;
const clubId = parseInt(club_id, 10);
return (
<AdminAuthGuard>
<FacilitySettingsComponent clubId={clubId} />
</AdminAuthGuard>
);
}

@ -0,0 +1,197 @@
'use client';
import { useState, useEffect } from '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 { addMember, listPlans } from '@/src/lib/api/facility-admin';
import type { AddMemberRequest, MemberRole, MemberStatus, MembershipPlan } from '@/src/types/facility-admin';
interface AddMemberModalProps {
isOpen: boolean;
onClose: () => void;
facilityId: number;
onSuccess: () => void;
}
export default function AddMemberModal({
isOpen,
onClose,
facilityId,
onSuccess
}: AddMemberModalProps) {
const [plans, setPlans] = useState<MembershipPlan[]>([]);
const [formData, setFormData] = useState<AddMemberRequest>({
app_user_id: 0,
role: 'member',
facility_membership_plan_id: null,
sport_id: null,
status: 'active',
starts_at: new Date().toISOString().split('T')[0],
ends_at: null
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchPlans();
}, [facilityId]);
async function fetchPlans() {
const result = await listPlans(facilityId, { include_inactive: false });
if (result.success) {
setPlans(result.data);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
if (formData.app_user_id === 0) {
setError('Please enter a valid user ID');
setSubmitting(false);
return;
}
const result = await addMember(facilityId, formData);
setSubmitting(false);
if (result.success) {
onSuccess();
onClose();
} else {
setError(result.error.detail || 'Failed to add member');
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="md">
<form onSubmit={handleSubmit}>
<ModalHeader onClose={onClose}>Add Member</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>
)}
<div className="space-y-4">
{/* User ID (simplified - in production, use email lookup) */}
<div>
<label htmlFor="app_user_id" className="block text-sm font-medium text-gray-700 mb-1">
User ID <span className="text-red-500">*</span>
</label>
<input
type="number"
id="app_user_id"
value={formData.app_user_id || ''}
onChange={e => setFormData({ ...formData, app_user_id: parseInt(e.target.value) || 0 })}
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"
placeholder="Enter user ID"
required
/>
<p className="text-xs text-gray-500 mt-1">Enter the app_user_id of the user to add</p>
</div>
{/* Role */}
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
Role <span className="text-red-500">*</span>
</label>
<select
id="role"
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as MemberRole })}
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"
required
>
<option value="guest">Guest</option>
<option value="member">Member</option>
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
</div>
{/* Membership Plan */}
<div>
<label htmlFor="plan" className="block text-sm font-medium text-gray-700 mb-1">
Membership Plan {formData.role === 'member' && <span className="text-red-500">*</span>}
</label>
<select
id="plan"
value={formData.facility_membership_plan_id || ''}
onChange={e => setFormData({ ...formData, facility_membership_plan_id: parseInt(e.target.value) || null })}
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"
required={formData.role === 'member'}
>
<option value="">No Plan</option>
{plans.map(plan => (
<option key={plan.facility_membership_plan_id} value={plan.facility_membership_plan_id}>
{plan.name} - CHF {(plan.price_cents / 100).toFixed(2)} / {plan.billing_period}
</option>
))}
</select>
</div>
{/* Status */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
id="status"
value={formData.status}
onChange={e => setFormData({ ...formData, status: e.target.value as MemberStatus })}
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"
>
<option value="active">Active</option>
<option value="trial">Trial</option>
<option value="grace">Grace Period</option>
<option value="past_due">Past Due</option>
<option value="paused">Paused</option>
</select>
</div>
{/* End Date */}
<div>
<label htmlFor="ends_at" className="block text-sm font-medium text-gray-700 mb-1">
End Date (Optional)
</label>
<input
type="date"
id="ends_at"
value={formData.ends_at || ''}
onChange={e => setFormData({ ...formData, ends_at: e.target.value || null })}
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"
/>
<p className="text-xs text-gray-500 mt-1">Leave empty for indefinite membership</p>
</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}
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 ? 'Adding...' : 'Add Member'}
</button>
</ModalFooter>
</form>
</Modal>
);
}

@ -0,0 +1,188 @@
'use client';
import { useState, useEffect } from '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 { updateMember, listPlans } from '@/src/lib/api/facility-admin';
import type { FacilityMember, UpdateMemberRequest, MemberRole, MemberStatus, MembershipPlan } from '@/src/types/facility-admin';
interface EditMemberModalProps {
isOpen: boolean;
onClose: () => void;
facilityId: number;
member: FacilityMember;
onSuccess: () => void;
}
export default function EditMemberModal({
isOpen,
onClose,
facilityId,
member,
onSuccess
}: EditMemberModalProps) {
const [plans, setPlans] = useState<MembershipPlan[]>([]);
const [formData, setFormData] = useState<UpdateMemberRequest>({
role: member.role,
facility_membership_plan_id: member.facility_membership_plan_id,
status: member.status,
ends_at: member.ends_at ? member.ends_at.split('T')[0] : null
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchPlans();
}, [facilityId]);
async function fetchPlans() {
const result = await listPlans(facilityId, { include_inactive: false });
if (result.success) {
setPlans(result.data);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
const result = await updateMember(facilityId, member.facility_member_id, formData);
setSubmitting(false);
if (result.success) {
onSuccess();
onClose();
} else {
setError(result.error.detail || 'Failed to update member');
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="md">
<form onSubmit={handleSubmit}>
<ModalHeader onClose={onClose}>Edit Member</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>
)}
{/* Member Info (read-only) */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-full flex items-center justify-center text-white font-bold text-lg">
{member.display_name.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-semibold text-gray-800">{member.display_name}</div>
<div className="text-sm text-gray-600">{member.email}</div>
</div>
</div>
</div>
<div className="space-y-4">
{/* Role */}
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
Role <span className="text-red-500">*</span>
</label>
<select
id="role"
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as MemberRole })}
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"
required
>
<option value="guest">Guest</option>
<option value="member">Member</option>
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
</div>
{/* Membership Plan */}
<div>
<label htmlFor="plan" className="block text-sm font-medium text-gray-700 mb-1">
Membership Plan {formData.role === 'member' && <span className="text-red-500">*</span>}
</label>
<select
id="plan"
value={formData.facility_membership_plan_id || ''}
onChange={e => setFormData({ ...formData, facility_membership_plan_id: parseInt(e.target.value) || null })}
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"
required={formData.role === 'member'}
>
<option value="">No Plan</option>
{plans.map(plan => (
<option key={plan.facility_membership_plan_id} value={plan.facility_membership_plan_id}>
{plan.name} - CHF {(plan.price_cents / 100).toFixed(2)} / {plan.billing_period}
</option>
))}
</select>
</div>
{/* Status */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
id="status"
value={formData.status}
onChange={e => setFormData({ ...formData, status: e.target.value as MemberStatus })}
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"
>
<option value="active">Active</option>
<option value="trial">Trial</option>
<option value="grace">Grace Period</option>
<option value="past_due">Past Due</option>
<option value="expired">Expired</option>
<option value="cancelled">Cancelled</option>
<option value="paused">Paused</option>
</select>
</div>
{/* End Date */}
<div>
<label htmlFor="ends_at" className="block text-sm font-medium text-gray-700 mb-1">
End Date (Optional)
</label>
<input
type="date"
id="ends_at"
value={formData.ends_at || ''}
onChange={e => setFormData({ ...formData, ends_at: e.target.value || null })}
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"
/>
<p className="text-xs text-gray-500 mt-1">Leave empty for indefinite membership</p>
</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}
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...' : 'Update Member'}
</button>
</ModalFooter>
</form>
</Modal>
);
}

@ -0,0 +1,76 @@
'use client';
import type { FacilityMember } from '@/src/types/facility-admin';
import RoleBadge from './RoleBadge';
import StatusBadge from './StatusBadge';
interface MemberCardProps {
member: FacilityMember;
onEdit: () => void;
onDelete: () => void;
}
export default function MemberCard({ member, onEdit, onDelete }: MemberCardProps) {
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm hover:shadow-md transition-all duration-200">
<div className="flex items-start justify-between">
{/* Member Info */}
<div className="flex items-center gap-4 flex-1">
{/* Avatar */}
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-full flex items-center justify-center text-white font-bold text-2xl flex-shrink-0">
{member.display_name.charAt(0).toUpperCase()}
</div>
{/* Details */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-bold text-gray-800">{member.display_name}</h3>
<RoleBadge role={member.role} />
<StatusBadge status={member.status} />
</div>
<div className="text-sm text-gray-600 mb-2">{member.email}</div>
<div className="flex flex-wrap gap-3 text-xs text-gray-500">
{member.plan_name && (
<div>
<span className="font-medium">Plan:</span> {member.plan_name}
</div>
)}
<div>
<span className="font-medium">Joined:</span> {formatDate(member.starts_at)}
</div>
{member.ends_at && (
<div>
<span className="font-medium">Ends:</span> {formatDate(member.ends_at)}
</div>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 ml-4">
<button
onClick={onEdit}
className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 font-medium transition-colors text-sm"
>
Edit
</button>
<button
onClick={onDelete}
className="px-4 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 font-medium transition-colors text-sm"
>
Remove
</button>
</div>
</div>
</div>
);
}

@ -0,0 +1,43 @@
'use client';
interface MemberListSkeletonProps {
count?: number;
}
export default function MemberListSkeleton({ count = 5 }: MemberListSkeletonProps) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm animate-pulse">
<div className="flex items-start justify-between">
{/* Member Info */}
<div className="flex items-center gap-4 flex-1">
{/* Avatar */}
<div className="w-16 h-16 bg-gray-200 rounded-full flex-shrink-0"></div>
{/* Details */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="h-5 bg-gray-200 rounded w-32"></div>
<div className="h-5 bg-gray-200 rounded w-16"></div>
<div className="h-5 bg-gray-200 rounded w-16"></div>
</div>
<div className="h-4 bg-gray-200 rounded w-48 mb-2"></div>
<div className="flex gap-3">
<div className="h-3 bg-gray-200 rounded w-24"></div>
<div className="h-3 bg-gray-200 rounded w-32"></div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 ml-4">
<div className="w-16 h-9 bg-gray-200 rounded-lg"></div>
<div className="w-20 h-9 bg-gray-200 rounded-lg"></div>
</div>
</div>
</div>
))}
</div>
);
}

@ -0,0 +1,24 @@
'use client';
import type { MemberRole } from '@/src/types/facility-admin';
interface RoleBadgeProps {
role: MemberRole;
}
export default function RoleBadge({ role }: RoleBadgeProps) {
const roleConfig: Record<MemberRole, { label: string; color: string }> = {
guest: { label: 'Guest', color: 'bg-gray-100 text-gray-700' },
member: { label: 'Member', color: 'bg-blue-100 text-blue-700' },
staff: { label: 'Staff', color: 'bg-green-100 text-green-700' },
admin: { label: 'Admin', color: 'bg-purple-100 text-purple-700' }
};
const config = roleConfig[role];
return (
<span className={`inline-flex items-center text-xs px-2 py-1 rounded-full font-medium ${config.color}`}>
{config.label}
</span>
);
}

@ -0,0 +1,27 @@
'use client';
import type { MemberStatus } from '@/src/types/facility-admin';
interface StatusBadgeProps {
status: MemberStatus;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const statusConfig: Record<MemberStatus, { label: string; color: string }> = {
active: { label: 'Active', color: 'bg-green-100 text-green-700' },
trial: { label: 'Trial', color: 'bg-blue-100 text-blue-700' },
grace: { label: 'Grace Period', color: 'bg-yellow-100 text-yellow-700' },
past_due: { label: 'Past Due', color: 'bg-orange-100 text-orange-700' },
expired: { label: 'Expired', color: 'bg-red-100 text-red-700' },
cancelled: { label: 'Cancelled', color: 'bg-gray-100 text-gray-700' },
paused: { label: 'Paused', color: 'bg-gray-100 text-gray-700' }
};
const config = statusConfig[status];
return (
<span className={`inline-flex items-center text-xs px-2 py-1 rounded-full font-medium ${config.color}`}>
{config.label}
</span>
);
}

@ -0,0 +1,80 @@
'use client';
import type { MembershipPlan } from '@/src/types/facility-admin';
interface PlanCardProps {
plan: MembershipPlan;
onEdit: () => void;
onDelete: () => void;
}
export default function PlanCard({ plan, onEdit, onDelete }: PlanCardProps) {
function formatPrice(cents: number): string {
return (cents / 100).toFixed(2);
}
function formatBillingPeriod(period: string): string {
const periodMap: Record<string, string> = {
daily: 'per day',
weekly: 'per week',
monthly: 'per month',
quarterly: 'per quarter',
yearly: 'per year',
lifetime: 'one-time'
};
return periodMap[period] || period;
}
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'
}`}>
{/* 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>
{/* Price */}
<div className="mb-6">
<div className="flex items-baseline">
<span className="text-3xl font-bold text-purple-600">
CHF {formatPrice(plan.price_cents)}
</span>
</div>
<div className="text-sm text-gray-600 mt-1">
{formatBillingPeriod(plan.billing_period)}
</div>
</div>
{/* Sport Badge */}
{plan.sport_id && (
<div className="mb-4">
<span className="inline-flex items-center text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full font-medium">
🎾 Sport-specific
</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-4 border-t border-gray-200">
<button
onClick={onEdit}
className="flex-1 px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 font-medium transition-colors"
>
Edit
</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"
>
Deactivate
</button>
</div>
</div>
);
}

@ -0,0 +1,174 @@
'use client';
import { useState } from '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 { createPlan, updatePlan } from '@/src/lib/api/facility-admin';
import type { MembershipPlan, CreatePlanRequest, BillingPeriod } from '@/src/types/facility-admin';
interface PlanFormModalProps {
isOpen: boolean;
onClose: () => void;
facilityId: number;
plan?: MembershipPlan;
onSuccess: () => void;
}
export default function PlanFormModal({
isOpen,
onClose,
facilityId,
plan,
onSuccess
}: PlanFormModalProps) {
const [formData, setFormData] = useState<CreatePlanRequest>({
name: plan?.name || '',
billing_period: plan?.billing_period || 'monthly',
price_cents: plan?.price_cents || 0,
sport_id: plan?.sport_id || null,
is_active: plan?.is_active ?? true
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
const result = plan
? await updatePlan(facilityId, plan.facility_membership_plan_id, formData)
: await createPlan(facilityId, formData);
setSubmitting(false);
if (result.success) {
onSuccess();
onClose();
} else {
setError(result.error.detail || 'Operation failed');
}
}
function formatCurrency(cents: number): string {
return (cents / 100).toFixed(2);
}
function parseCurrency(value: string): number {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : Math.round(parsed * 100);
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="md">
<form onSubmit={handleSubmit}>
<ModalHeader onClose={onClose}>
{plan ? 'Edit Plan' : 'Create Plan'}
</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>
)}
<div className="space-y-4">
{/* Plan Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Plan Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={e => setFormData({ ...formData, name: 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 focus:border-transparent"
placeholder="e.g., Gold Member, Annual Pass"
required
/>
<p className="text-xs text-gray-500 mt-1">A descriptive name for this membership plan</p>
</div>
{/* Billing Period */}
<div>
<label htmlFor="billing_period" className="block text-sm font-medium text-gray-700 mb-1">
Billing Period <span className="text-red-500">*</span>
</label>
<select
id="billing_period"
value={formData.billing_period}
onChange={e => setFormData({ ...formData, billing_period: e.target.value as BillingPeriod })}
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"
required
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
<option value="lifetime">Lifetime</option>
</select>
</div>
{/* Price */}
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
Price (CHF) <span className="text-red-500">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-2 text-gray-500">CHF</span>
<input
type="number"
id="price"
value={formatCurrency(formData.price_cents)}
onChange={e => setFormData({ ...formData, price_cents: parseCurrency(e.target.value) })}
step="0.01"
min="0"
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"
placeholder="0.00"
required
/>
</div>
</div>
{/* Active Status */}
<div className="flex items-center">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={e => setFormData({ ...formData, is_active: e.target.checked })}
className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<label htmlFor="is_active" className="ml-2 text-sm text-gray-700">
Active (available for subscription)
</label>
</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}
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...' : plan ? 'Update Plan' : 'Create Plan'}
</button>
</ModalFooter>
</form>
</Modal>
);
}

@ -0,0 +1,37 @@
'use client';
interface PlanListSkeletonProps {
count?: number;
}
export default function PlanListSkeleton({ count = 3 }: PlanListSkeletonProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="bg-white border-2 border-gray-200 rounded-xl p-6 shadow-sm animate-pulse">
{/* Header */}
<div className="flex justify-between items-start mb-4">
<div className="h-6 bg-gray-200 rounded w-2/3"></div>
</div>
{/* Price */}
<div className="mb-6">
<div className="h-8 bg-gray-200 rounded w-1/2 mb-1"></div>
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
</div>
{/* Sport Badge Placeholder */}
<div className="mb-4">
<div className="h-5 bg-gray-200 rounded w-1/4"></div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-4 border-t border-gray-200">
<div className="flex-1 h-10 bg-gray-200 rounded-lg"></div>
<div className="flex-1 h-10 bg-gray-200 rounded-lg"></div>
</div>
</div>
))}
</div>
);
}

@ -0,0 +1,73 @@
'use client';
import type { AccessModel } from '@/src/types/facility-admin';
interface AccessModelSelectorProps {
value: AccessModel;
onChange: (value: AccessModel) => void;
}
export default function AccessModelSelector({ value, onChange }: AccessModelSelectorProps) {
const accessModels: Array<{
value: AccessModel;
label: string;
description: string;
icon: string;
}> = [
{
value: 'membership_required',
label: 'Membership Required',
description: 'Only members can book slots. Non-members cannot access the facility.',
icon: '🔒'
},
{
value: 'payg_allowed',
label: 'Pay-as-you-go Allowed',
description: 'Non-members can book slots by paying per booking via Stripe.',
icon: '💳'
},
{
value: 'open',
label: 'Open Access',
description: 'Free access for everyone. Anyone can book without payment or membership.',
icon: '🌐'
}
];
return (
<div className="space-y-3">
{accessModels.map((model) => (
<div
key={model.value}
onClick={() => onChange(model.value)}
className={`cursor-pointer border-2 rounded-lg p-4 transition-all duration-200 ${
value === model.value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300 bg-white'
}`}
>
<div className="flex items-start gap-3">
<div className="text-3xl flex-shrink-0">{model.icon}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-bold text-gray-800">{model.label}</h3>
{value === model.value && (
<span className="text-xs bg-purple-600 text-white px-2 py-1 rounded-full">
Active
</span>
)}
</div>
<p className="text-sm text-gray-600">{model.description}</p>
</div>
<input
type="radio"
checked={value === model.value}
onChange={() => onChange(model.value)}
className="w-5 h-5 text-purple-600 focus:ring-purple-500 flex-shrink-0 mt-1"
/>
</div>
</div>
))}
</div>
);
}

@ -0,0 +1,92 @@
'use client';
interface BookingLimitsFormProps {
maxFutureBookingDays: number;
maxActiveBookings: number;
requireVerifiedEmail: boolean;
onChange: (updates: {
max_future_booking_days?: number;
max_active_bookings?: number;
require_verified_email?: boolean;
}) => void;
}
export default function BookingLimitsForm({
maxFutureBookingDays,
maxActiveBookings,
requireVerifiedEmail,
onChange
}: BookingLimitsFormProps) {
return (
<div className="space-y-6">
{/* Max Future Booking Days */}
<div>
<label htmlFor="max_future_days" className="block text-sm font-medium text-gray-700 mb-2">
Maximum advance booking window
</label>
<div className="flex items-center gap-4">
<input
type="range"
id="max_future_days"
value={maxFutureBookingDays}
onChange={e => onChange({ max_future_booking_days: parseInt(e.target.value) })}
min="1"
max="90"
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-purple-600"
/>
<div className="w-20 text-right">
<span className="text-lg font-bold text-purple-600">{maxFutureBookingDays}</span>
<span className="text-sm text-gray-600 ml-1">days</span>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">
How far in advance can users book slots? (1-90 days)
</p>
</div>
{/* Max Active Bookings */}
<div>
<label htmlFor="max_active_bookings" className="block text-sm font-medium text-gray-700 mb-2">
Maximum concurrent active bookings
</label>
<div className="flex items-center gap-4">
<input
type="range"
id="max_active_bookings"
value={maxActiveBookings}
onChange={e => onChange({ max_active_bookings: parseInt(e.target.value) })}
min="1"
max="20"
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-purple-600"
/>
<div className="w-20 text-right">
<span className="text-lg font-bold text-purple-600">{maxActiveBookings}</span>
<span className="text-sm text-gray-600 ml-1">slots</span>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">
Maximum number of future bookings a user can have at once (1-20)
</p>
</div>
{/* Require Verified Email */}
<div className="flex items-start gap-3">
<input
type="checkbox"
id="require_verified_email"
checked={requireVerifiedEmail}
onChange={e => onChange({ require_verified_email: e.target.checked })}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500 mt-0.5"
/>
<div className="flex-1">
<label htmlFor="require_verified_email" className="block text-sm font-medium text-gray-700 cursor-pointer">
Require verified email for bookings
</label>
<p className="text-xs text-gray-500 mt-1">
Users must verify their email address before they can make bookings
</p>
</div>
</div>
</div>
);
}

@ -0,0 +1,41 @@
'use client';
interface GuestPricingInputProps {
value: number; // price in cents
onChange: (value: number) => void;
}
export default function GuestPricingInput({ value, onChange }: GuestPricingInputProps) {
function formatCurrency(cents: number): string {
return (cents / 100).toFixed(2);
}
function parseCurrency(value: string): number {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : Math.round(parsed * 100);
}
return (
<div>
<label htmlFor="guest_price" className="block text-sm font-medium text-gray-700 mb-2">
Price per booking for non-members
</label>
<div className="relative max-w-xs">
<span className="absolute left-3 top-3 text-gray-500 font-medium">CHF</span>
<input
type="number"
id="guest_price"
value={formatCurrency(value)}
onChange={e => onChange(parseCurrency(e.target.value))}
step="0.01"
min="0"
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"
placeholder="0.00"
/>
</div>
<p className="text-xs text-gray-500 mt-2">
Amount charged to non-members for each booking. Set to 0 for free access.
</p>
</div>
);
}

@ -0,0 +1,575 @@
/**
* Facility Admin API Client
*
* Handles all facility admin operations: membership plans, entitlements, members, policy
* Build: 495
* Date: 2025-11-24
*/
import type {
MembershipPlan,
CreatePlanRequest,
UpdatePlanRequest,
PlanEntitlements,
SetEntitlementRequest,
FacilityMember,
AddMemberRequest,
UpdateMemberRequest,
MemberListFilters,
FacilityPolicy,
UpdatePolicyRequest,
FacilityAdminError,
FacilityAdminApiResult,
} from '@/src/types/facility-admin';
import apiFetch from '@/src/utils/apiFetch';
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Handle API response
*/
async function handleApiResponse<T>(response: Response): Promise<FacilityAdminApiResult<T>> {
if (response.ok) {
const data = await response.json();
return { success: true, data };
}
// Handle error responses (RFC-7807)
try {
const error: FacilityAdminError = await response.json();
return { success: false, error };
} catch {
// Fallback for non-JSON errors
return {
success: false,
error: {
type: 'about:blank',
title: 'API Error',
status: response.status,
detail: response.statusText || 'An unexpected error occurred',
code: 'internal_error',
},
};
}
}
// ============================================================================
// Membership Plans
// ============================================================================
/**
* POST /admin/facilities/{facility_id}/plans
* Create a new membership plan
*/
export async function createPlan(
facilityId: number,
request: CreatePlanRequest
): Promise<FacilityAdminApiResult<MembershipPlan>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/plans`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<MembershipPlan>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to create plan',
code: 'internal_error',
},
};
}
}
/**
* GET /admin/facilities/{facility_id}/plans
* List all membership plans for a facility
*/
export async function listPlans(
facilityId: number,
filters?: { sport_id?: number; include_inactive?: boolean }
): Promise<FacilityAdminApiResult<MembershipPlan[]>> {
try {
const params = new URLSearchParams();
if (filters?.sport_id !== undefined) params.set('sport_id', String(filters.sport_id));
if (filters?.include_inactive !== undefined) params.set('include_inactive', String(filters.include_inactive));
const queryString = params.toString();
const endpoint = `/admin/facilities/${facilityId}/plans${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<MembershipPlan[]>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to list plans',
code: 'internal_error',
},
};
}
}
/**
* GET /admin/facilities/{facility_id}/plans/{plan_id}
* Get a single membership plan
*/
export async function getPlan(
facilityId: number,
planId: number
): Promise<FacilityAdminApiResult<MembershipPlan>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<MembershipPlan>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to get plan',
code: 'internal_error',
},
};
}
}
/**
* PATCH /admin/facilities/{facility_id}/plans/{plan_id}
* Update a membership plan (partial update)
*/
export async function updatePlan(
facilityId: number,
planId: number,
request: UpdatePlanRequest
): Promise<FacilityAdminApiResult<MembershipPlan>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<MembershipPlan>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to update plan',
code: 'internal_error',
},
};
}
}
/**
* DELETE /admin/facilities/{facility_id}/plans/{plan_id}
* Delete a membership plan (soft delete by default)
*/
export async function deletePlan(
facilityId: number,
planId: number,
hardDelete: boolean = false
): Promise<FacilityAdminApiResult<void>> {
try {
const params = new URLSearchParams();
if (hardDelete) params.set('hard_delete', 'true');
const queryString = params.toString();
const endpoint = `/admin/facilities/${facilityId}/plans/${planId}${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'DELETE',
});
return handleApiResponse<void>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to delete plan',
code: 'internal_error',
},
};
}
}
// ============================================================================
// Entitlements
// ============================================================================
/**
* GET /admin/facilities/{facility_id}/plans/{plan_id}/entitlements
* Get all entitlements for a plan
*/
export async function getEntitlements(
facilityId: number,
planId: number
): Promise<FacilityAdminApiResult<PlanEntitlements>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}/entitlements`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<PlanEntitlements>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to get entitlements',
code: 'internal_error',
},
};
}
}
/**
* PUT /admin/facilities/{facility_id}/plans/{plan_id}/entitlements
* Replace all entitlements for a plan (bulk update)
*/
export async function updateEntitlements(
facilityId: number,
planId: number,
entitlements: PlanEntitlements
): Promise<FacilityAdminApiResult<PlanEntitlements>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}/entitlements`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entitlements),
});
return handleApiResponse<PlanEntitlements>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to update entitlements',
code: 'internal_error',
},
};
}
}
/**
* PUT /admin/facilities/{facility_id}/plans/{plan_id}/entitlements/{key}
* Set or update a single entitlement
*/
export async function setEntitlement(
facilityId: number,
planId: number,
key: string,
request: SetEntitlementRequest
): Promise<FacilityAdminApiResult<{ key: string; value: string | number | boolean }>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}/entitlements/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<{ key: string; value: string | number | boolean }>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to set entitlement',
code: 'internal_error',
},
};
}
}
/**
* DELETE /admin/facilities/{facility_id}/plans/{plan_id}/entitlements/{key}
* Delete a specific entitlement
*/
export async function deleteEntitlement(
facilityId: number,
planId: number,
key: string
): Promise<FacilityAdminApiResult<void>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}/entitlements/${key}`, {
method: 'DELETE',
});
return handleApiResponse<void>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to delete entitlement',
code: 'internal_error',
},
};
}
}
// ============================================================================
// Members
// ============================================================================
/**
* POST /admin/facilities/{facility_id}/members
* Add a member to a facility
*/
export async function addMember(
facilityId: number,
request: AddMemberRequest
): Promise<FacilityAdminApiResult<FacilityMember>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<FacilityMember>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to add member',
code: 'internal_error',
},
};
}
}
/**
* GET /admin/facilities/{facility_id}/members
* List all members for a facility
*/
export async function listMembers(
facilityId: number,
filters?: MemberListFilters
): Promise<FacilityAdminApiResult<FacilityMember[]>> {
try {
const params = new URLSearchParams();
if (filters?.sport_id !== undefined) params.set('sport_id', String(filters.sport_id));
if (filters?.status) params.set('status', filters.status);
if (filters?.role) params.set('role', filters.role);
if (filters?.plan_id !== undefined) params.set('plan_id', String(filters.plan_id));
const queryString = params.toString();
const endpoint = `/admin/facilities/${facilityId}/members${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<FacilityMember[]>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to list members',
code: 'internal_error',
},
};
}
}
/**
* GET /admin/facilities/{facility_id}/members/{member_id}
* Get a single member
*/
export async function getMember(
facilityId: number,
memberId: number
): Promise<FacilityAdminApiResult<FacilityMember>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/members/${memberId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<FacilityMember>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to get member',
code: 'internal_error',
},
};
}
}
/**
* PATCH /admin/facilities/{facility_id}/members/{member_id}
* Update a member (partial update)
*/
export async function updateMember(
facilityId: number,
memberId: number,
request: UpdateMemberRequest
): Promise<FacilityAdminApiResult<FacilityMember>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/members/${memberId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<FacilityMember>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to update member',
code: 'internal_error',
},
};
}
}
/**
* DELETE /admin/facilities/{facility_id}/members/{member_id}
* Delete a member (soft delete by default)
*/
export async function deleteMember(
facilityId: number,
memberId: number,
hardDelete: boolean = false
): Promise<FacilityAdminApiResult<void>> {
try {
const params = new URLSearchParams();
if (hardDelete) params.set('hard_delete', 'true');
const queryString = params.toString();
const endpoint = `/admin/facilities/${facilityId}/members/${memberId}${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'DELETE',
});
return handleApiResponse<void>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to delete member',
code: 'internal_error',
},
};
}
}
// ============================================================================
// Policy
// ============================================================================
/**
* GET /admin/facilities/{facility_id}/policy
* Get facility policy (creates default if doesn't exist)
*/
export async function getPolicy(
facilityId: number
): Promise<FacilityAdminApiResult<FacilityPolicy>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/policy`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<FacilityPolicy>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to get policy',
code: 'internal_error',
},
};
}
}
/**
* PATCH /admin/facilities/{facility_id}/policy
* Update facility policy (partial update, upsert)
*/
export async function updatePolicy(
facilityId: number,
request: UpdatePolicyRequest
): Promise<FacilityAdminApiResult<FacilityPolicy>> {
try {
const response = await apiFetch(`/admin/facilities/${facilityId}/policy`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<FacilityPolicy>(response);
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to update policy',
code: 'internal_error',
},
};
}
}

@ -0,0 +1,226 @@
/**
* Facility Admin TypeScript Types
*
* Based on: Admin API Endpoints - Facility Management
* Build: 495
* Date: 2025-11-24
* Phase: Phase B1 (Frontend Setup & Types)
*/
// ============================================================================
// Membership Plans
// ============================================================================
export interface MembershipPlan {
facility_membership_plan_id: number;
facility_id: number;
name: string;
billing_period: BillingPeriod;
price_cents: number;
sport_id: number | null;
is_active: boolean;
created_at?: string; // ISO8601
updated_at?: string; // ISO8601
}
export type BillingPeriod = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly' | 'lifetime';
export interface CreatePlanRequest {
name: string;
billing_period: BillingPeriod;
price_cents: number;
sport_id?: number | null;
is_active?: boolean;
}
export interface UpdatePlanRequest {
name?: string;
billing_period?: BillingPeriod;
price_cents?: number;
is_active?: boolean;
}
// ============================================================================
// Entitlements
// ============================================================================
export interface PlanEntitlements {
advance_window_days?: number;
max_active_bookings?: number;
can_book?: boolean;
drop_in_allowed?: boolean;
price_group?: string;
daily_free_bookings?: number;
over_quota_price_cents?: number;
}
export interface SetEntitlementRequest {
value: string | number | boolean;
}
// ============================================================================
// Members
// ============================================================================
export type MemberRole = 'guest' | 'member' | 'staff' | 'admin';
export type MemberStatus = 'active' | 'trial' | 'grace' | 'past_due' | 'expired' | 'cancelled' | 'paused';
export interface FacilityMember {
facility_member_id: number;
facility_id: number;
app_user_id: number;
display_name: string;
email: string;
role: MemberRole;
status: MemberStatus;
facility_membership_plan_id: number | null;
plan_name?: string | null;
sport_id: number | null;
starts_at: string; // ISO8601
ends_at: string | null; // ISO8601
source: string;
created_at: string; // ISO8601
updated_at?: string; // ISO8601
}
export interface AddMemberRequest {
app_user_id: number;
role: MemberRole;
facility_membership_plan_id?: number | null;
sport_id?: number | null;
status?: MemberStatus;
starts_at?: string; // ISO8601
ends_at?: string | null; // ISO8601
}
export interface UpdateMemberRequest {
role?: MemberRole;
facility_membership_plan_id?: number | null;
status?: MemberStatus;
ends_at?: string | null; // ISO8601
}
export interface MemberListFilters {
sport_id?: number;
status?: MemberStatus;
role?: MemberRole;
plan_id?: number;
}
// ============================================================================
// Policies
// ============================================================================
export type AccessModel = 'membership_required' | 'payg_allowed' | 'open';
export interface FacilityPolicy {
facility_id: number;
access_model: AccessModel;
default_price_group: string | null;
guest_price_cents: number;
require_verified_email: boolean;
max_future_booking_days: number;
max_active_bookings: number;
created_at?: string; // ISO8601
updated_at?: string; // ISO8601
}
export interface UpdatePolicyRequest {
access_model?: AccessModel;
guest_price_cents?: number;
default_price_group?: string | null;
require_verified_email?: boolean;
max_future_booking_days?: number;
max_active_bookings?: number;
}
// ============================================================================
// Error Types (RFC-7807)
// ============================================================================
export interface FacilityAdminError {
type: string;
title: string;
status: number;
detail: string;
code: FacilityAdminErrorCode;
errors?: ValidationError[];
meta?: Record<string, any>;
}
export interface ValidationError {
field: string;
message: string;
}
export type FacilityAdminErrorCode =
| 'validation_error'
| 'not_found'
| 'unauthorized'
| 'forbidden'
| 'not_admin_for_club'
| 'internal_error'
| 'duplicate_plan'
| 'plan_has_members'
| 'member_already_exists'
| 'invalid_plan'
| 'invalid_user';
// ============================================================================
// API Result Wrapper
// ============================================================================
export type FacilityAdminApiResult<T> =
| { success: true; data: T }
| { success: false; error: FacilityAdminError };
// ============================================================================
// User-Friendly Error Messages
// ============================================================================
export const ERROR_MESSAGES: Record<FacilityAdminErrorCode, { title: string; hint: string }> = {
validation_error: {
title: 'Please fix the highlighted fields.',
hint: 'Check your input and try again.',
},
not_found: {
title: 'Not found.',
hint: 'The resource you requested does not exist.',
},
unauthorized: {
title: 'Not authenticated.',
hint: 'Please log in and try again.',
},
forbidden: {
title: 'Access denied.',
hint: "You don't have permission to perform this action.",
},
not_admin_for_club: {
title: 'No admin access.',
hint: "You don't have admin permission for this facility.",
},
internal_error: {
title: 'Something went wrong.',
hint: 'Please try again later.',
},
duplicate_plan: {
title: 'Plan already exists.',
hint: 'A plan with this name already exists for this facility.',
},
plan_has_members: {
title: 'Cannot delete plan.',
hint: 'This plan has active members. Reassign them first.',
},
member_already_exists: {
title: 'Member already exists.',
hint: 'This user is already a member of this facility.',
},
invalid_plan: {
title: 'Invalid membership plan.',
hint: 'The selected plan does not exist or is not available.',
},
invalid_user: {
title: 'Invalid user.',
hint: 'The selected user does not exist.',
},
};
Loading…
Cancel
Save