feat: add facility admin management UI (plans, members, policy)
continuous-integration/drone/push Build is passing
Details
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
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…
Reference in New Issue