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