40c56770a2
- Added AdminBlocksManagement component for managing court blocks. - Implemented functionality to create, edit, and delete blocks. - Integrated fetching of courts and blocks from the API. - Added validation for block creation and editing forms. - Enhanced UI with responsive design for mobile and desktop views. - Created database migration for court_blocks table and updated users table with theme_preference.
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { toast } from '@/hooks/use-toast';
|
|
import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw, Ban } from 'lucide-react';
|
|
import { AdminBlocksManagement } from './AdminBlocksManagement';
|
|
|
|
interface Court {
|
|
id: string;
|
|
name: string;
|
|
isActive: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
interface CourtFormData {
|
|
name: string;
|
|
isActive: boolean;
|
|
}
|
|
|
|
export function AdminCourtManagement() {
|
|
const [courts, setCourts] = useState<Court[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [creating, setCreating] = useState(false);
|
|
const [editing, setEditing] = useState<string | null>(null);
|
|
const [deleting, setDeleting] = useState<string | null>(null);
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
|
|
const [courtToDelete, setCourtToDelete] = useState<Court | null>(null);
|
|
const [formData, setFormData] = useState<CourtFormData>({
|
|
name: '',
|
|
isActive: true,
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchCourts();
|
|
}, []);
|
|
|
|
const fetchCourts = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch('/api/admin/courts');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setCourts(data.courts || []);
|
|
} else {
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to fetch courts',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching courts:', error);
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to fetch courts',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!formData.name.trim()) {
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Court name is required',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (editingCourt) {
|
|
setEditing(editingCourt.id);
|
|
const response = await fetch(`/api/admin/courts/${editingCourt.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData),
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Court updated successfully',
|
|
});
|
|
await fetchCourts();
|
|
resetForm();
|
|
} else {
|
|
const error = await response.json();
|
|
toast({
|
|
title: 'Error',
|
|
description: error.error || 'Failed to update court',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
} else {
|
|
setCreating(true);
|
|
const response = await fetch('/api/admin/courts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData),
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Court created successfully',
|
|
});
|
|
await fetchCourts();
|
|
resetForm();
|
|
} else {
|
|
const error = await response.json();
|
|
toast({
|
|
title: 'Error',
|
|
description: error.error || 'Failed to create court',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving court:', error);
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to save court',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setCreating(false);
|
|
setEditing(null);
|
|
}
|
|
};
|
|
|
|
const handleEdit = (court: Court) => {
|
|
setEditingCourt(court);
|
|
setFormData({
|
|
name: court.name,
|
|
isActive: court.isActive,
|
|
});
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
const openDeleteDialog = (court: Court) => {
|
|
setCourtToDelete(court);
|
|
setIsDeleteDialogOpen(true);
|
|
};
|
|
|
|
const confirmDeleteCourt = async () => {
|
|
if (courtToDelete) {
|
|
await handleDelete(courtToDelete.id);
|
|
setIsDeleteDialogOpen(false);
|
|
setCourtToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (courtId: string) => {
|
|
try {
|
|
setDeleting(courtId);
|
|
const response = await fetch(`/api/admin/courts/${courtId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Court deleted successfully',
|
|
});
|
|
await fetchCourts();
|
|
} else {
|
|
const error = await response.json();
|
|
toast({
|
|
title: 'Error',
|
|
description: error.error || 'Failed to delete court',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting court:', error);
|
|
toast({
|
|
title: 'Error',
|
|
description: 'Failed to delete court',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setDeleting(null);
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormData({ name: '', isActive: true });
|
|
setEditingCourt(null);
|
|
setIsDialogOpen(false);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Courts & Closures</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className='space-y-4'>
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
|
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
|
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className='space-y-6'>
|
|
<Tabs defaultValue='courts' className='w-full'>
|
|
<TabsList className='grid w-full grid-cols-2'>
|
|
<TabsTrigger value='courts' className='flex items-center gap-2'>
|
|
<MapPin className='h-4 w-4' />
|
|
Courts
|
|
</TabsTrigger>
|
|
<TabsTrigger value='closures' className='flex items-center gap-2'>
|
|
<Ban className='h-4 w-4' />
|
|
Closures
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value='courts'>
|
|
<Card>
|
|
<CardHeader className='flex flex-row items-center justify-between'>
|
|
<CardTitle className='flex items-center gap-2'>
|
|
<Settings className='h-5 w-5' />
|
|
Court Management
|
|
</CardTitle>
|
|
<div className='flex gap-2'>
|
|
<Button size='sm' variant='outline' onClick={fetchCourts}>
|
|
<RefreshCw className='h-4 w-4 mr-2' />
|
|
Refresh
|
|
</Button>
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size='sm' onClick={() => setEditingCourt(null)}>
|
|
<Plus className='h-4 w-4 mr-2' />
|
|
Add Court
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingCourt ? 'Edit Court' : 'Create New Court'}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
|
<div>
|
|
<Label htmlFor='name'>Court Name</Label>
|
|
<Input
|
|
id='name'
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder='e.g., Court 1, Main Court'
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className='flex items-center space-x-2'>
|
|
<Switch
|
|
id='isActive'
|
|
checked={formData.isActive}
|
|
onCheckedChange={(checked: boolean) =>
|
|
setFormData({ ...formData, isActive: checked })
|
|
}
|
|
/>
|
|
<Label htmlFor='isActive'>Active (available for booking)</Label>
|
|
</div>
|
|
|
|
<div className='flex justify-end space-x-2'>
|
|
<Button type='button' variant='outline' onClick={resetForm}>
|
|
Cancel
|
|
</Button>
|
|
<Button type='submit' disabled={creating || Boolean(editing)}>
|
|
{creating || editing ? (
|
|
<>
|
|
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
|
{editingCourt ? 'Updating...' : 'Creating...'}
|
|
</>
|
|
) : editingCourt ? (
|
|
'Update Court'
|
|
) : (
|
|
'Create Court'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{courts.length === 0 ? (
|
|
<div className='text-center py-8 text-gray-500'>
|
|
<MapPin className='h-12 w-12 mx-auto mb-4 text-gray-300' />
|
|
<p>No courts found. Create your first court to get started.</p>
|
|
</div>
|
|
) : (
|
|
<div className='space-y-4'>
|
|
{courts.map((court) => (
|
|
<div key={court.id} className='border rounded-lg p-4'>
|
|
<div className='flex items-center justify-between'>
|
|
<div className='flex items-center gap-3'>
|
|
<MapPin className='h-5 w-5 text-blue-600' />
|
|
<div>
|
|
<h3 className='font-medium'>{court.name}</h3>
|
|
<p className='text-sm text-gray-500'>
|
|
Created{' '}
|
|
{new Date(court.createdAt).toLocaleDateString('en-IE')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='flex items-center gap-3'>
|
|
<Badge variant={court.isActive ? 'default' : 'secondary'}>
|
|
{court.isActive ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
|
|
<div className='flex gap-1'>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
onClick={() => handleEdit(court)}
|
|
disabled={editing === court.id}
|
|
>
|
|
<Edit className='h-4 w-4' />
|
|
</Button>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
onClick={() => openDeleteDialog(court)}
|
|
disabled={deleting === court.id}
|
|
className='text-red-600 hover:text-red-700'
|
|
>
|
|
{deleting === court.id ? (
|
|
<RefreshCw className='h-4 w-4 animate-spin' />
|
|
) : (
|
|
<Trash2 className='h-4 w-4' />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete{' '}
|
|
{courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? This action cannot
|
|
be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmDeleteCourt}
|
|
className='bg-destructive hover:bg-destructive/90'
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value='closures'>
|
|
<AdminBlocksManagement />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|