You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
547 lines
17 KiB
TypeScript
547 lines
17 KiB
TypeScript
'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<Court[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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<Court | null>(null);
|
|
const [dependencies, setDependencies] = useState<CourtDependencies | null>(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 (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="w-8 h-8 text-slate-600 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (error && courts.length === 0) {
|
|
return (
|
|
<div className="bg-red-50 border-2 border-red-200 rounded-2xl p-6">
|
|
<div className="flex items-start space-x-3">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
<p className="text-red-700">{error}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Header with Add button */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-900">Courts</h2>
|
|
<p className="text-slate-600">Manage court inventory for this club</p>
|
|
</div>
|
|
<button
|
|
onClick={handleAdd}
|
|
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors"
|
|
>
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Add Court
|
|
</button>
|
|
</div>
|
|
|
|
{/* Empty state */}
|
|
{courts.length === 0 ? (
|
|
<div className="bg-slate-50 border-2 border-slate-200 rounded-2xl p-12">
|
|
<div className="flex flex-col items-center text-center space-y-6">
|
|
<div className="text-6xl">🎾</div>
|
|
<h3 className="text-2xl font-bold text-slate-900">
|
|
No Courts Yet
|
|
</h3>
|
|
<p className="text-slate-600 max-w-md leading-relaxed">
|
|
Add your first court to start managing slot definitions and bookings for this club.
|
|
</p>
|
|
<button
|
|
onClick={handleAdd}
|
|
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors"
|
|
>
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Add First Court
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Court list */
|
|
<div className="space-y-4">
|
|
{courts.map((court) => (
|
|
<div
|
|
key={court.court_id}
|
|
className="bg-white border-2 border-slate-200 rounded-2xl p-6 hover:border-slate-300 transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<h3 className="text-xl font-bold text-slate-900 mb-2">
|
|
{court.name}
|
|
</h3>
|
|
<p className="text-sm text-slate-600">
|
|
ID: {court.court_id} · Created {formatTimestamp(court.created_at)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={() => handleEdit(court)}
|
|
className="p-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
|
|
title="Edit"
|
|
>
|
|
<Edit className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteClick(court)}
|
|
className="p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Modal */}
|
|
{showAddModal && (
|
|
<CourtFormModal
|
|
clubId={clubId}
|
|
onClose={closeModals}
|
|
onSuccess={handleSuccess}
|
|
/>
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
{showEditModal && selectedCourt && (
|
|
<CourtFormModal
|
|
clubId={clubId}
|
|
court={selectedCourt}
|
|
onClose={closeModals}
|
|
onSuccess={handleSuccess}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{showDeleteModal && selectedCourt && (
|
|
<DeleteConfirmationModal
|
|
clubId={clubId}
|
|
court={selectedCourt}
|
|
onClose={closeModals}
|
|
onSuccess={handleSuccess}
|
|
/>
|
|
)}
|
|
|
|
{/* Dependencies Blocking Modal */}
|
|
{showDependenciesModal && selectedCourt && dependencies && (
|
|
<DependenciesBlockingModal
|
|
court={selectedCourt}
|
|
dependencies={dependencies}
|
|
onClose={closeModals}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 === 'court_name_duplicate') {
|
|
setFieldError(result.error.detail);
|
|
} else {
|
|
setError(result.error.detail);
|
|
}
|
|
}
|
|
|
|
setSaving(false);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-2xl p-8 max-w-md w-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-2xl font-bold text-slate-900">
|
|
{isEditing ? 'Edit Court' : 'Add Court'}
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|
disabled={saving}
|
|
>
|
|
<X className="w-5 h-5 text-slate-600" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div className="flex items-start space-x-3">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-red-700">{error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-900 mb-2">
|
|
Court Name <span className="text-red-600">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => 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 && (
|
|
<p className="mt-1 text-sm text-red-600">{fieldError}</p>
|
|
)}
|
|
<p className="mt-2 text-xs text-slate-500">
|
|
Examples: "Court 1", "North Court", "VIP Court"
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end space-x-3 pt-4 border-t-2 border-slate-100">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-6 py-3 text-slate-700 font-semibold rounded-lg hover:bg-slate-100 transition-colors"
|
|
disabled={saving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="inline-flex items-center px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50"
|
|
disabled={saving}
|
|
>
|
|
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
{isEditing ? 'Save' : 'Add'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-2xl p-8 max-w-md w-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-2xl font-bold text-slate-900">Delete Court?</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|
disabled={deleting}
|
|
>
|
|
<X className="w-5 h-5 text-slate-600" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div className="flex items-start space-x-3">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-red-700">{error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="mb-6">
|
|
<p className="text-slate-700 leading-relaxed">
|
|
Are you sure you want to delete <strong>"{court.name}"</strong>?
|
|
</p>
|
|
<p className="text-slate-600 mt-2">
|
|
This action cannot be undone.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end space-x-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-6 py-3 text-slate-700 font-semibold rounded-lg hover:bg-slate-100 transition-colors"
|
|
disabled={deleting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleDelete}
|
|
className="inline-flex items-center px-6 py-3 bg-red-600 text-white font-semibold rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
|
disabled={deleting}
|
|
>
|
|
{deleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Dependencies Blocking Modal
|
|
*/
|
|
interface DependenciesBlockingModalProps {
|
|
court: Court;
|
|
dependencies: CourtDependencies;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function DependenciesBlockingModal({ court, dependencies, onClose }: DependenciesBlockingModalProps) {
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-2xl p-8 max-w-md w-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center space-x-3">
|
|
<AlertTriangle className="w-6 h-6 text-amber-600" />
|
|
<h2 className="text-2xl font-bold text-slate-900">Cannot Delete Court</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
<X className="w-5 h-5 text-slate-600" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="space-y-4">
|
|
<p className="text-slate-700 leading-relaxed">
|
|
Court <strong>"{court.name}"</strong> cannot be deleted because it is referenced by:
|
|
</p>
|
|
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 space-y-2">
|
|
{dependencies.dependencies.slot_definitions > 0 && (
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-slate-700">Slot definitions:</span>
|
|
<span className="font-bold text-slate-900">
|
|
{dependencies.dependencies.slot_definitions}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{dependencies.dependencies.slot_instances_future > 0 && (
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-slate-700">Future slot instances:</span>
|
|
<span className="font-bold text-slate-900">
|
|
{dependencies.dependencies.slot_instances_future}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{dependencies.dependencies.slot_instances_booked > 0 && (
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-slate-700">Booked slot instances:</span>
|
|
<span className="font-bold text-slate-900">
|
|
{dependencies.dependencies.slot_instances_booked}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm text-slate-600 leading-relaxed">
|
|
To delete this court, you must first:
|
|
</p>
|
|
|
|
<ol className="list-decimal list-inside space-y-1 text-sm text-slate-700">
|
|
{dependencies.dependencies.slot_definitions > 0 && (
|
|
<li>Delete or reassign all slot definitions</li>
|
|
)}
|
|
{dependencies.dependencies.slot_instances_future > 0 && (
|
|
<li>Delete future slot instances</li>
|
|
)}
|
|
{dependencies.dependencies.slot_instances_booked > 0 && (
|
|
<li>Cancel or move all booked slot instances</li>
|
|
)}
|
|
</ol>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end space-x-3 pt-6 border-t-2 border-slate-100 mt-6">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-6 py-3 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|