diff --git a/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx b/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx new file mode 100644 index 0000000..6ebfbaf --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ArrowLeft, Loader2, AlertCircle, Lock } from 'lucide-react'; +import Link from 'next/link'; +import { getAdminClubDetail } from '@/src/lib/api/admin-clubs'; +import type { AdminClubDetail, AdminApiError } from '@/src/types/admin-api'; +import useTranslation from '@/src/hooks/useTranslation'; +import ClubProfileTab from './tabs/ClubProfileTab'; +import ClubCourtsTab from './tabs/ClubCourtsTab'; + +interface ClubDetailTabsProps { + clubId: number; +} + +type TabKey = 'profile' | 'courts' | 'slot-definitions'; + +export default function ClubDetailTabs({ clubId }: ClubDetailTabsProps) { + const { t, locale } = useTranslation(); + const [clubDetail, setClubDetail] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('profile'); + + useEffect(() => { + loadClubDetail(); + }, [clubId]); + + async function loadClubDetail() { + setLoading(true); + const result = await getAdminClubDetail(clubId); + + if (result.success) { + setClubDetail(result.data); + setError(null); + } else { + setError(result.error); + setClubDetail(null); + } + + setLoading(false); + } + + // Loading state + if (loading) { + return ( +
+
+ +

Loading club details...

+
+
+ ); + } + + // Authentication error (401) + if (error && error.status === 401) { + return ( +
+
+
+
+ +
+

+ Authentication Required +

+

+ Please log in to access the venue management portal. +

+
+
+
+
+
+ ); + } + + // Forbidden error (403) + if (error && error.status === 403) { + return ( +
+
+
+
+ +
+

+ Access Denied +

+

+ {error.detail} +

+ + + Back to clubs + +
+
+
+
+
+ ); + } + + // Other API errors + if (error) { + return ( +
+
+
+
+ +
+

+ Error Loading Club +

+

+ {error.detail} +

+

+ Error code: {error.code} +

+ + + Back to clubs + +
+
+
+
+
+ ); + } + + if (!clubDetail) { + return null; + } + + // Success - render tabbed interface + return ( +
+ {/* Breadcrumb */} +
+ + + Back to clubs + +
+ + {/* Club Header */} +
+

+ {clubDetail.club.name} +

+

+ {clubDetail.club.timezone} +

+
+ + {/* Tab Navigation */} +
+
+ + + + Slot Definitions + +
+
+ + {/* Tab Content */} + {activeTab === 'profile' && ( + + )} + + {activeTab === 'courts' && ( + + )} +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/page.tsx b/src/app/[locale]/admin/clubs/[club_id]/page.tsx index 81c086b..c8ebd35 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/page.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/page.tsx @@ -1,4 +1,4 @@ -import AdminClubDetailComponent from './AdminClubDetail'; +import ClubDetailTabs from './ClubDetailTabs'; import AdminAuthGuard from '@/src/components/AdminAuthGuard'; export default async function AdminClubDetailPage({ @@ -11,7 +11,7 @@ export default async function AdminClubDetailPage({ return ( - + ); } diff --git a/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubCourtsTab.tsx b/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubCourtsTab.tsx new file mode 100644 index 0000000..d4e3928 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubCourtsTab.tsx @@ -0,0 +1,535 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Loader2, AlertCircle, Plus, Edit, Trash2, AlertTriangle, X } from 'lucide-react'; +import { + getCourts, + createCourt, + updateCourt, + deleteCourt, + getCourtDependencies, +} from '@/src/lib/api/courts'; +import type { Court, CourtRequest, CourtDependencies } from '@/src/types/courts'; +import { formatTimestamp } from '@/src/types/courts'; + +interface ClubCourtsTabProps { + clubId: number; +} + +export default function ClubCourtsTab({ clubId }: ClubCourtsTabProps) { + const [courts, setCourts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Modal state + const [showAddModal, setShowAddModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showDependenciesModal, setShowDependenciesModal] = useState(false); + + const [selectedCourt, setSelectedCourt] = useState(null); + const [dependencies, setDependencies] = useState(null); + + useEffect(() => { + loadCourts(); + }, [clubId]); + + async function loadCourts() { + setLoading(true); + const result = await getCourts(clubId); + + if (result.success) { + setCourts(result.data); + setError(null); + } else { + setError(result.error.detail); + } + + setLoading(false); + } + + function handleAdd() { + setShowAddModal(true); + } + + function handleEdit(court: Court) { + setSelectedCourt(court); + setShowEditModal(true); + } + + async function handleDeleteClick(court: Court) { + setSelectedCourt(court); + + // Check dependencies first + const result = await getCourtDependencies(clubId, court.court_id); + + if (result.success) { + setDependencies(result.data); + + if (result.data.can_delete) { + // No dependencies, show delete confirmation + setShowDeleteModal(true); + } else { + // Has dependencies, show blocking modal + setShowDependenciesModal(true); + } + } else { + setError(result.error.detail); + } + } + + function closeModals() { + setShowAddModal(false); + setShowEditModal(false); + setShowDeleteModal(false); + setShowDependenciesModal(false); + setSelectedCourt(null); + setDependencies(null); + } + + async function handleSuccess() { + await loadCourts(); + closeModals(); + } + + // Loading state + if (loading) { + return ( +
+ +
+ ); + } + + // Error state + if (error && courts.length === 0) { + return ( +
+
+ +

{error}

+
+
+ ); + } + + return ( +
+ {/* Header with Add button */} +
+
+

Courts

+

Manage court inventory for this club

+
+ +
+ + {/* Empty state */} + {courts.length === 0 ? ( +
+
+
🎾
+

+ No Courts Yet +

+

+ Add your first court to start managing slot definitions and bookings for this club. +

+ +
+
+ ) : ( + /* Court list */ +
+ {courts.map((court) => ( +
+
+
+

+ {court.name} +

+

+ ID: {court.court_id} · Created {formatTimestamp(court.created_at)} +

+
+ +
+ + +
+
+
+ ))} +
+ )} + + {/* Add Modal */} + {showAddModal && ( + + )} + + {/* Edit Modal */} + {showEditModal && selectedCourt && ( + + )} + + {/* Delete Confirmation Modal */} + {showDeleteModal && selectedCourt && ( + + )} + + {/* Dependencies Blocking Modal */} + {showDependenciesModal && selectedCourt && dependencies && ( + + )} +
+ ); +} + +/** + * Court Form Modal (Add/Edit) + */ +interface CourtFormModalProps { + clubId: number; + court?: Court; + onClose: () => void; + onSuccess: () => void; +} + +function CourtFormModal({ clubId, court, onClose, onSuccess }: CourtFormModalProps) { + const isEditing = !!court; + const [name, setName] = useState(court?.name || ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [fieldError, setFieldError] = useState(''); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (!name || name.trim().length === 0) { + setFieldError('Court name is required'); + return; + } + + setSaving(true); + setError(''); + setFieldError(''); + + const request: CourtRequest = { + name: name.trim(), + }; + + const result = isEditing + ? await updateCourt(clubId, court!.court_id, request) + : await createCourt(clubId, request); + + if (result.success) { + onSuccess(); + } else { + if (result.error.code === 'validation_error' && result.error.errors) { + const nameError = result.error.errors.find(e => e.field === 'name'); + if (nameError) { + setFieldError(nameError.message); + } + } else if (result.error.code === 'duplicate_court_name') { + setFieldError(result.error.detail); + } else { + setError(result.error.detail); + } + } + + setSaving(false); + } + + return ( +
+
+ {/* Header */} +
+

+ {isEditing ? 'Edit Court' : 'Add Court'} +

+ +
+ + {/* Error */} + {error && ( +
+
+ +

{error}

+
+
+ )} + + {/* Form */} +
+
+ + setName(e.target.value)} + placeholder='e.g., "Court 1", "North Court", "VIP Court"' + maxLength={100} + className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${ + fieldError + ? 'border-red-300 focus:border-red-500' + : 'border-slate-200 focus:border-slate-900' + } focus:outline-none`} + disabled={saving} + autoFocus + /> + {fieldError && ( +

{fieldError}

+ )} +

+ Examples: "Court 1", "North Court", "VIP Court" +

+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} + +/** + * Delete Confirmation Modal + */ +interface DeleteConfirmationModalProps { + clubId: number; + court: Court; + onClose: () => void; + onSuccess: () => void; +} + +function DeleteConfirmationModal({ clubId, court, onClose, onSuccess }: DeleteConfirmationModalProps) { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(''); + + async function handleDelete() { + setDeleting(true); + setError(''); + + const result = await deleteCourt(clubId, court.court_id); + + if (result.success) { + onSuccess(); + } else { + setError(result.error.detail); + } + + setDeleting(false); + } + + return ( +
+
+ {/* Header */} +
+

Delete Court?

+ +
+ + {/* Error */} + {error && ( +
+
+ +

{error}

+
+
+ )} + + {/* Content */} +
+

+ Are you sure you want to delete "{court.name}"? +

+

+ This action cannot be undone. +

+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} + +/** + * Dependencies Blocking Modal + */ +interface DependenciesBlockingModalProps { + court: Court; + dependencies: CourtDependencies; + onClose: () => void; +} + +function DependenciesBlockingModal({ court, dependencies, onClose }: DependenciesBlockingModalProps) { + return ( +
+
+ {/* Header */} +
+
+ +

Cannot Delete Court

+
+ +
+ + {/* Content */} +
+

+ Court "{court.name}" cannot be deleted because it is referenced by: +

+ +
+ {dependencies.dependencies.slot_definitions > 0 && ( +
+ Slot definitions: + + {dependencies.dependencies.slot_definitions} + +
+ )} + {dependencies.dependencies.upcoming_bookings > 0 && ( +
+ Upcoming bookings: + + {dependencies.dependencies.upcoming_bookings} + +
+ )} +
+ +

+ To delete this court, you must first: +

+ +
    + {dependencies.dependencies.slot_definitions > 0 && ( +
  1. Delete or reassign all slot definitions
  2. + )} + {dependencies.dependencies.upcoming_bookings > 0 && ( +
  3. Cancel or move all upcoming bookings
  4. + )} +
+
+ + {/* Actions */} +
+ +
+
+
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubProfileTab.tsx b/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubProfileTab.tsx new file mode 100644 index 0000000..c5b898c --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubProfileTab.tsx @@ -0,0 +1,458 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Loader2, AlertCircle, CheckCircle } from 'lucide-react'; +import { getClubProfile, updateClubProfile } from '@/src/lib/api/courts'; +import type { ClubProfile, ClubProfileUpdateRequest} from '@/src/types/courts'; +import { COMMON_TIMEZONES, isValidEmail, isValidUrl } from '@/src/types/courts'; + +interface ClubProfileTabProps { + clubId: number; + onUpdate?: () => void; +} + +export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps) { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [errors, setErrors] = useState>({}); + + // Form state + const [name, setName] = useState(''); + const [timezone, setTimezone] = useState(''); + const [addressLine1, setAddressLine1] = useState(''); + const [addressLine2, setAddressLine2] = useState(''); + const [city, setCity] = useState(''); + const [postalCode, setPostalCode] = useState(''); + const [country, setCountry] = useState(''); + const [phone, setPhone] = useState(''); + const [email, setEmail] = useState(''); + const [website, setWebsite] = useState(''); + + useEffect(() => { + loadProfile(); + }, [clubId]); + + async function loadProfile() { + setLoading(true); + const result = await getClubProfile(clubId); + + if (result.success) { + const prof = result.data; + setProfile(prof); + + // Populate form + setName(prof.name); + setTimezone(prof.timezone); + setAddressLine1(prof.address_line_1 || ''); + setAddressLine2(prof.address_line_2 || ''); + setCity(prof.city || ''); + setPostalCode(prof.postal_code || ''); + setCountry(prof.country || ''); + setPhone(prof.phone || ''); + setEmail(prof.email || ''); + setWebsite(prof.website || ''); + + setError(null); + } else { + setError(result.error.detail); + } + + setLoading(false); + } + + function validateForm(): boolean { + const newErrors: Record = {}; + + if (!name || name.trim().length === 0) { + newErrors.name = 'Name is required'; + } + + if (!timezone) { + newErrors.timezone = 'Timezone is required'; + } + + if (email && !isValidEmail(email)) { + newErrors.email = 'Invalid email format'; + } + + if (website && !isValidUrl(website)) { + newErrors.website = 'Invalid URL format (must include http:// or https://)'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setSaving(true); + setError(null); + setSuccess(false); + + const request: ClubProfileUpdateRequest = { + name: name.trim(), + timezone, + address_line_1: addressLine1.trim() || undefined, + address_line_2: addressLine2.trim() || undefined, + city: city.trim() || undefined, + postal_code: postalCode.trim() || undefined, + country: country.trim() || undefined, + phone: phone.trim() || undefined, + email: email.trim() || undefined, + website: website.trim() || undefined, + }; + + const result = await updateClubProfile(clubId, request); + + if (result.success) { + setProfile(result.data); + setSuccess(true); + setError(null); + + // Call parent update callback + if (onUpdate) { + onUpdate(); + } + + // Hide success message after 3 seconds + setTimeout(() => setSuccess(false), 3000); + } else { + // Handle validation errors + if (result.error.code === 'validation_error' && result.error.errors) { + const fieldErrors: Record = {}; + result.error.errors.forEach(err => { + fieldErrors[err.field] = err.message; + }); + setErrors(fieldErrors); + } else { + setError(result.error.detail); + } + } + + setSaving(false); + } + + function handleCancel() { + if (!profile) return; + + // Reset form to original values + setName(profile.name); + setTimezone(profile.timezone); + setAddressLine1(profile.address_line_1 || ''); + setAddressLine2(profile.address_line_2 || ''); + setCity(profile.city || ''); + setPostalCode(profile.postal_code || ''); + setCountry(profile.country || ''); + setPhone(profile.phone || ''); + setEmail(profile.email || ''); + setWebsite(profile.website || ''); + setErrors({}); + setError(null); + } + + // Loading state + if (loading) { + return ( +
+ +
+ ); + } + + // Error state + if (error && !profile) { + return ( +
+
+ +

{error}

+
+
+ ); + } + + if (!profile) return null; + + return ( +
+ {/* Success message */} + {success && ( +
+
+ +

Profile updated successfully

+
+
+ )} + + {/* Error message */} + {error && ( +
+
+ +

{error}

+
+
+ )} + +
+ {/* Basic Information */} +
+

Basic Information

+ +
+ {/* Name */} +
+ + setName(e.target.value)} + className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${ + errors.name + ? 'border-red-300 focus:border-red-500' + : 'border-slate-200 focus:border-slate-900' + } focus:outline-none`} + disabled={saving} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Timezone */} +
+ + + {errors.timezone && ( +

{errors.timezone}

+ )} +
+
+
+ + {/* Location */} +
+

Location

+ +
+ {/* Address Line 1 */} +
+ + setAddressLine1(e.target.value)} + placeholder="123 High Street" + maxLength={200} + className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors" + disabled={saving} + /> +
+ + {/* Address Line 2 */} +
+ + setAddressLine2(e.target.value)} + placeholder="Building A" + maxLength={200} + className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors" + disabled={saving} + /> +
+ + {/* City & Postal Code */} +
+
+ + setCity(e.target.value)} + placeholder="London" + maxLength={100} + className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors" + disabled={saving} + /> +
+ +
+ + setPostalCode(e.target.value)} + placeholder="SW1A 1AA" + maxLength={20} + className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors" + disabled={saving} + /> +
+
+ + {/* Country */} +
+ + setCountry(e.target.value)} + placeholder="United Kingdom" + maxLength={100} + className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors" + disabled={saving} + /> +
+
+
+ + {/* Contact */} +
+

Contact

+ +
+ {/* Phone */} +
+ + setPhone(e.target.value)} + placeholder="+44 20 1234 5678" + maxLength={50} + className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg font-medium focus:border-slate-900 focus:outline-none transition-colors" + disabled={saving} + /> +
+ + {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="info@centralpadel.com" + maxLength={100} + className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${ + errors.email + ? 'border-red-300 focus:border-red-500' + : 'border-slate-200 focus:border-slate-900' + } focus:outline-none`} + disabled={saving} + /> + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* Website */} +
+ + setWebsite(e.target.value)} + placeholder="https://www.centralpadel.com" + maxLength={200} + className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${ + errors.website + ? 'border-red-300 focus:border-red-500' + : 'border-slate-200 focus:border-slate-900' + } focus:outline-none`} + disabled={saving} + /> + {errors.website && ( +

{errors.website}

+ )} +
+
+
+ + {/* Integration (read-only) */} +
+

Integration

+ +
+
+ Provider: + {profile.provider} +
+ {profile.provider !== 'local' && profile.remote_club_id && ( +
+ Remote Club ID: + {profile.remote_club_id} +
+ )} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +}