From 5dd283525b0d8b8dc92e3cc745690c63f9671f38 Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Mon, 24 Nov 2025 10:09:29 +0100 Subject: [PATCH] feat: add facility admin management UI (plans, members, policy) 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. --- .../members/MembersManagementComponent.tsx | 231 +++++++ .../admin/clubs/[club_id]/members/page.tsx | 17 + .../plans/MembershipPlansComponent.tsx | 125 ++++ .../admin/clubs/[club_id]/plans/page.tsx | 17 + .../settings/FacilitySettingsComponent.tsx | 160 +++++ .../admin/clubs/[club_id]/settings/page.tsx | 17 + src/components/members/AddMemberModal.tsx | 197 ++++++ src/components/members/EditMemberModal.tsx | 188 ++++++ src/components/members/MemberCard.tsx | 76 +++ src/components/members/MemberListSkeleton.tsx | 43 ++ src/components/members/RoleBadge.tsx | 24 + src/components/members/StatusBadge.tsx | 27 + src/components/plans/PlanCard.tsx | 80 +++ src/components/plans/PlanFormModal.tsx | 174 ++++++ src/components/plans/PlanListSkeleton.tsx | 37 ++ src/components/policy/AccessModelSelector.tsx | 73 +++ src/components/policy/BookingLimitsForm.tsx | 92 +++ src/components/policy/GuestPricingInput.tsx | 41 ++ src/lib/api/facility-admin.ts | 575 ++++++++++++++++++ src/types/facility-admin.ts | 226 +++++++ 20 files changed, 2420 insertions(+) create mode 100644 src/app/[locale]/admin/clubs/[club_id]/members/MembersManagementComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/members/page.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/plans/MembershipPlansComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/plans/page.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/settings/FacilitySettingsComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/settings/page.tsx create mode 100644 src/components/members/AddMemberModal.tsx create mode 100644 src/components/members/EditMemberModal.tsx create mode 100644 src/components/members/MemberCard.tsx create mode 100644 src/components/members/MemberListSkeleton.tsx create mode 100644 src/components/members/RoleBadge.tsx create mode 100644 src/components/members/StatusBadge.tsx create mode 100644 src/components/plans/PlanCard.tsx create mode 100644 src/components/plans/PlanFormModal.tsx create mode 100644 src/components/plans/PlanListSkeleton.tsx create mode 100644 src/components/policy/AccessModelSelector.tsx create mode 100644 src/components/policy/BookingLimitsForm.tsx create mode 100644 src/components/policy/GuestPricingInput.tsx create mode 100644 src/lib/api/facility-admin.ts create mode 100644 src/types/facility-admin.ts diff --git a/src/app/[locale]/admin/clubs/[club_id]/members/MembersManagementComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/members/MembersManagementComponent.tsx new file mode 100644 index 0000000..fed26a6 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/members/MembersManagementComponent.tsx @@ -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([]); + const [filteredMembers, setFilteredMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [addModalOpen, setAddModalOpen] = useState(false); + const [editingMember, setEditingMember] = useState(null); + + // Filters + const [searchQuery, setSearchQuery] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState('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 ( +
+ {/* Header */} +
+
+

Members

+

Manage facility members and their access

+
+ +
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Filters */} +
+
+ {/* Search */} +
+ + 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" + /> +
+ + {/* Role Filter */} +
+ + +
+ + {/* Status Filter */} +
+ + +
+
+
+ + {/* Members List */} + {loading ? ( + + ) : filteredMembers.length === 0 ? ( +
+
👥
+

+ {searchQuery || roleFilter ? 'No members found' : 'No members yet'} +

+

+ {searchQuery || roleFilter + ? 'Try adjusting your filters' + : 'Add your first member to get started'} +

+ {!searchQuery && !roleFilter && ( + + )} +
+ ) : ( +
+ {filteredMembers.map(member => ( + setEditingMember(member)} + onDelete={() => handleDelete(member.facility_member_id, member.display_name)} + /> + ))} +
+ )} + + {/* Add Modal */} + {addModalOpen && ( + setAddModalOpen(false)} + facilityId={clubId} + onSuccess={fetchMembers} + /> + )} + + {/* Edit Modal */} + {editingMember && ( + setEditingMember(null)} + facilityId={clubId} + member={editingMember} + onSuccess={fetchMembers} + /> + )} +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/members/page.tsx b/src/app/[locale]/admin/clubs/[club_id]/members/page.tsx new file mode 100644 index 0000000..7ccaf7d --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/members/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/plans/MembershipPlansComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/plans/MembershipPlansComponent.tsx new file mode 100644 index 0000000..b0eeb1f --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/plans/MembershipPlansComponent.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editingPlan, setEditingPlan] = useState(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 ( +
+ {/* Header */} +
+
+

Membership Plans

+

Create and manage subscription plans for your facility

+
+ +
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Plans Grid */} + {loading ? ( + + ) : plans.length === 0 ? ( +
+
📋
+

No membership plans yet

+

Create your first plan to get started

+ +
+ ) : ( +
+ {plans.map(plan => ( + setEditingPlan(plan)} + onDelete={() => handleDelete(plan.facility_membership_plan_id, plan.name)} + /> + ))} +
+ )} + + {/* Create Modal */} + {createModalOpen && ( + setCreateModalOpen(false)} + facilityId={clubId} + onSuccess={fetchPlans} + /> + )} + + {/* Edit Modal */} + {editingPlan && ( + setEditingPlan(null)} + facilityId={clubId} + plan={editingPlan} + onSuccess={fetchPlans} + /> + )} +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/plans/page.tsx b/src/app/[locale]/admin/clubs/[club_id]/plans/page.tsx new file mode 100644 index 0000000..2146cc4 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/plans/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/settings/FacilitySettingsComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/settings/FacilitySettingsComponent.tsx new file mode 100644 index 0000000..dcd860c --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/settings/FacilitySettingsComponent.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(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 ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (!policy) { + return ( +
+
+ Failed to load facility policy +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Facility Settings

+

Configure access model, pricing, and booking policies

+
+ + {/* Success Message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Settings Form */} +
+ {/* Access Model Section */} +
+

Access Model

+ setPolicy({ ...policy, access_model: value })} + /> +
+ + {/* Guest Pricing Section (conditional) */} + {policy.access_model === 'payg_allowed' && ( +
+

Guest Pricing

+ setPolicy({ ...policy, guest_price_cents: value })} + /> +
+ )} + + {/* Booking Limits Section */} +
+

Booking Limits

+ setPolicy({ ...policy, ...updates })} + /> +
+ + {/* Save Button */} +
+ +
+
+
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/settings/page.tsx b/src/app/[locale]/admin/clubs/[club_id]/settings/page.tsx new file mode 100644 index 0000000..c911b81 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/settings/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/components/members/AddMemberModal.tsx b/src/components/members/AddMemberModal.tsx new file mode 100644 index 0000000..0c8d88d --- /dev/null +++ b/src/components/members/AddMemberModal.tsx @@ -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([]); + const [formData, setFormData] = useState({ + 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(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 ( + +
+ Add Member + + + {error && ( +
+ {error} +
+ )} + +
+ {/* User ID (simplified - in production, use email lookup) */} +
+ + 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 + /> +

Enter the app_user_id of the user to add

+
+ + {/* Role */} +
+ + +
+ + {/* Membership Plan */} +
+ + +
+ + {/* Status */} +
+ + +
+ + {/* End Date */} +
+ + 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" + /> +

Leave empty for indefinite membership

+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/components/members/EditMemberModal.tsx b/src/components/members/EditMemberModal.tsx new file mode 100644 index 0000000..29f9346 --- /dev/null +++ b/src/components/members/EditMemberModal.tsx @@ -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([]); + const [formData, setFormData] = useState({ + 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(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 ( + +
+ Edit Member + + + {error && ( +
+ {error} +
+ )} + + {/* Member Info (read-only) */} +
+
+
+ {member.display_name.charAt(0).toUpperCase()} +
+
+
{member.display_name}
+
{member.email}
+
+
+
+ +
+ {/* Role */} +
+ + +
+ + {/* Membership Plan */} +
+ + +
+ + {/* Status */} +
+ + +
+ + {/* End Date */} +
+ + 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" + /> +

Leave empty for indefinite membership

+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/components/members/MemberCard.tsx b/src/components/members/MemberCard.tsx new file mode 100644 index 0000000..18950b1 --- /dev/null +++ b/src/components/members/MemberCard.tsx @@ -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 ( +
+
+ {/* Member Info */} +
+ {/* Avatar */} +
+ {member.display_name.charAt(0).toUpperCase()} +
+ + {/* Details */} +
+
+

{member.display_name}

+ + +
+
{member.email}
+
+ {member.plan_name && ( +
+ Plan: {member.plan_name} +
+ )} +
+ Joined: {formatDate(member.starts_at)} +
+ {member.ends_at && ( +
+ Ends: {formatDate(member.ends_at)} +
+ )} +
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/src/components/members/MemberListSkeleton.tsx b/src/components/members/MemberListSkeleton.tsx new file mode 100644 index 0000000..9f949a3 --- /dev/null +++ b/src/components/members/MemberListSkeleton.tsx @@ -0,0 +1,43 @@ +'use client'; + +interface MemberListSkeletonProps { + count?: number; +} + +export default function MemberListSkeleton({ count = 5 }: MemberListSkeletonProps) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+ {/* Member Info */} +
+ {/* Avatar */} +
+ + {/* Details */} +
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Actions */} +
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/components/members/RoleBadge.tsx b/src/components/members/RoleBadge.tsx new file mode 100644 index 0000000..8a0c1a7 --- /dev/null +++ b/src/components/members/RoleBadge.tsx @@ -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 = { + 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 ( + + {config.label} + + ); +} diff --git a/src/components/members/StatusBadge.tsx b/src/components/members/StatusBadge.tsx new file mode 100644 index 0000000..93bc19d --- /dev/null +++ b/src/components/members/StatusBadge.tsx @@ -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 = { + 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 ( + + {config.label} + + ); +} diff --git a/src/components/plans/PlanCard.tsx b/src/components/plans/PlanCard.tsx new file mode 100644 index 0000000..b60f8ed --- /dev/null +++ b/src/components/plans/PlanCard.tsx @@ -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 = { + daily: 'per day', + weekly: 'per week', + monthly: 'per month', + quarterly: 'per quarter', + yearly: 'per year', + lifetime: 'one-time' + }; + return periodMap[period] || period; + } + + return ( +
+ {/* Header */} +
+

{plan.name}

+ {!plan.is_active && ( + + Inactive + + )} +
+ + {/* Price */} +
+
+ + CHF {formatPrice(plan.price_cents)} + +
+
+ {formatBillingPeriod(plan.billing_period)} +
+
+ + {/* Sport Badge */} + {plan.sport_id && ( +
+ + 🎾 Sport-specific + +
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/src/components/plans/PlanFormModal.tsx b/src/components/plans/PlanFormModal.tsx new file mode 100644 index 0000000..83b8ffd --- /dev/null +++ b/src/components/plans/PlanFormModal.tsx @@ -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({ + 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(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 ( + +
+ + {plan ? 'Edit Plan' : 'Create Plan'} + + + + {error && ( +
+ {error} +
+ )} + +
+ {/* Plan Name */} +
+ + 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 + /> +

A descriptive name for this membership plan

+
+ + {/* Billing Period */} +
+ + +
+ + {/* Price */} +
+ +
+ CHF + 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 + /> +
+
+ + {/* Active Status */} +
+ setFormData({ ...formData, is_active: e.target.checked })} + className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" + /> + +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/components/plans/PlanListSkeleton.tsx b/src/components/plans/PlanListSkeleton.tsx new file mode 100644 index 0000000..5a5ec11 --- /dev/null +++ b/src/components/plans/PlanListSkeleton.tsx @@ -0,0 +1,37 @@ +'use client'; + +interface PlanListSkeletonProps { + count?: number; +} + +export default function PlanListSkeleton({ count = 3 }: PlanListSkeletonProps) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ {/* Header */} +
+
+
+ + {/* Price */} +
+
+
+
+ + {/* Sport Badge Placeholder */} +
+
+
+ + {/* Actions */} +
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/components/policy/AccessModelSelector.tsx b/src/components/policy/AccessModelSelector.tsx new file mode 100644 index 0000000..e344907 --- /dev/null +++ b/src/components/policy/AccessModelSelector.tsx @@ -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 ( +
+ {accessModels.map((model) => ( +
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' + }`} + > +
+
{model.icon}
+
+
+

{model.label}

+ {value === model.value && ( + + Active + + )} +
+

{model.description}

+
+ onChange(model.value)} + className="w-5 h-5 text-purple-600 focus:ring-purple-500 flex-shrink-0 mt-1" + /> +
+
+ ))} +
+ ); +} diff --git a/src/components/policy/BookingLimitsForm.tsx b/src/components/policy/BookingLimitsForm.tsx new file mode 100644 index 0000000..7942ec6 --- /dev/null +++ b/src/components/policy/BookingLimitsForm.tsx @@ -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 ( +
+ {/* Max Future Booking Days */} +
+ +
+ 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" + /> +
+ {maxFutureBookingDays} + days +
+
+

+ How far in advance can users book slots? (1-90 days) +

+
+ + {/* Max Active Bookings */} +
+ +
+ 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" + /> +
+ {maxActiveBookings} + slots +
+
+

+ Maximum number of future bookings a user can have at once (1-20) +

+
+ + {/* Require Verified Email */} +
+ 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" + /> +
+ +

+ Users must verify their email address before they can make bookings +

+
+
+
+ ); +} diff --git a/src/components/policy/GuestPricingInput.tsx b/src/components/policy/GuestPricingInput.tsx new file mode 100644 index 0000000..f29f55a --- /dev/null +++ b/src/components/policy/GuestPricingInput.tsx @@ -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 ( +
+ +
+ CHF + 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" + /> +
+

+ Amount charged to non-members for each booking. Set to 0 for free access. +

+
+ ); +} diff --git a/src/lib/api/facility-admin.ts b/src/lib/api/facility-admin.ts new file mode 100644 index 0000000..fbff788 --- /dev/null +++ b/src/lib/api/facility-admin.ts @@ -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(response: Response): Promise> { + 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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/plans`, { + 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 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> { + 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(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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}`, { + 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 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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}`, { + method: 'PATCH', + 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 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> { + 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(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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}/entitlements`, { + 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 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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}/entitlements`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entitlements), + }); + + 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 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> { + 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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/plans/${planId}/entitlements/${key}`, { + method: 'DELETE', + }); + + 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 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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/members`, { + 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 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> { + 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(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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/members/${memberId}`, { + 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 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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/members/${memberId}`, { + method: 'PATCH', + 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 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> { + 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(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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/policy`, { + 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 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> { + try { + const response = await apiFetch(`/admin/facilities/${facilityId}/policy`, { + method: 'PATCH', + 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 update policy', + code: 'internal_error', + }, + }; + } +} diff --git a/src/types/facility-admin.ts b/src/types/facility-admin.ts new file mode 100644 index 0000000..acda7b3 --- /dev/null +++ b/src/types/facility-admin.ts @@ -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; +} + +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 = + | { success: true; data: T } + | { success: false; error: FacilityAdminError }; + +// ============================================================================ +// User-Friendly Error Messages +// ============================================================================ + +export const ERROR_MESSAGES: Record = { + 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.', + }, +};