Files
tt-booking/components/admin/AdminBlocksManagement.tsx
T
mikicvi 40c56770a2 feat: implement admin blocks management feature
- 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.
2025-12-29 17:04:16 +00:00

754 lines
22 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { Calendar, Trash2, Plus, Ban, CalendarX, Edit, Bell } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface Court {
id: string;
name: string;
}
interface CourtBlock {
id: string;
courtId: string | null;
courtName: string | null;
date: string;
startTime: string;
endTime: string;
reason: string;
createdBy: string;
creatorName: string;
createdAt: string;
}
export function AdminBlocksManagement() {
const { toast } = useToast();
const [blocks, setBlocks] = useState<CourtBlock[]>([]);
const [courts, setCourts] = useState<Court[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
// Form state for creating
const [selectedCourt, setSelectedCourt] = useState<string>('all');
const [blockDate, setBlockDate] = useState<string>('');
const [startTime, setStartTime] = useState<string>('18:00');
const [endTime, setEndTime] = useState<string>('23:00');
const [reason, setReason] = useState<string>('');
const [createAnnouncement, setCreateAnnouncement] = useState<boolean>(true);
// Edit modal state
const [editingBlock, setEditingBlock] = useState<CourtBlock | null>(null);
const [editCourt, setEditCourt] = useState<string>('all');
const [editDate, setEditDate] = useState<string>('');
const [editStartTime, setEditStartTime] = useState<string>('');
const [editEndTime, setEditEndTime] = useState<string>('');
const [editReason, setEditReason] = useState<string>('');
// Generate time options (e.g., 06:00 to 23:00)
const timeOptions = [];
for (let h = 6; h <= 23; h++) {
timeOptions.push(`${String(h).padStart(2, '0')}:00`);
}
const fetchBlocks = useCallback(async () => {
try {
const response = await fetch('/api/admin/blocks');
if (response.ok) {
const data = await response.json();
setBlocks(data.blocks || []);
} else {
toast({
title: 'Error',
description: 'Failed to fetch blocks',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error fetching blocks:', error);
toast({
title: 'Error',
description: 'Failed to fetch blocks',
variant: 'destructive',
});
} finally {
setLoading(false);
}
}, [toast]);
const fetchCourts = useCallback(async () => {
try {
const response = await fetch('/api/courts');
if (response.ok) {
const data = await response.json();
setCourts(data.courts || []);
}
} catch (error) {
console.error('Error fetching courts:', error);
}
}, []);
useEffect(() => {
fetchBlocks();
fetchCourts();
}, [fetchBlocks, fetchCourts]);
const handleCreateBlock = async (e: React.FormEvent) => {
e.preventDefault();
if (!blockDate) {
toast({
title: 'Validation Error',
description: 'Please select a date',
variant: 'destructive',
});
return;
}
if (!reason.trim()) {
toast({
title: 'Validation Error',
description: 'Please provide a reason for the block',
variant: 'destructive',
});
return;
}
if (startTime >= endTime) {
toast({
title: 'Validation Error',
description: 'End time must be after start time',
variant: 'destructive',
});
return;
}
setSubmitting(true);
try {
const response = await fetch('/api/admin/blocks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
courtId: selectedCourt === 'all' ? null : selectedCourt,
date: blockDate,
startTime,
endTime,
reason: reason.trim(),
createAnnouncement,
}),
});
if (response.ok) {
const data = await response.json();
toast({
title: 'Success',
description: data.announcementCreated
? 'Block created and announcement posted'
: 'Block created successfully',
});
// Reset form
setBlockDate('');
setReason('');
setSelectedCourt('all');
setStartTime('18:00');
setEndTime('22:00');
setCreateAnnouncement(true);
// Refresh blocks list
fetchBlocks();
} else {
const data = await response.json();
toast({
title: 'Error',
description: data.error || 'Failed to create block',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error creating block:', error);
toast({
title: 'Error',
description: 'Failed to create block',
variant: 'destructive',
});
} finally {
setSubmitting(false);
}
};
const handleEditClick = (block: CourtBlock) => {
setEditingBlock(block);
setEditCourt(block.courtId || 'all');
setEditDate(block.date);
setEditStartTime(block.startTime);
setEditEndTime(block.endTime);
setEditReason(block.reason);
};
const handleEditSave = async () => {
if (!editingBlock) return;
if (!editDate || !editReason.trim()) {
toast({
title: 'Validation Error',
description: 'Please fill in all required fields',
variant: 'destructive',
});
return;
}
if (editStartTime >= editEndTime) {
toast({
title: 'Validation Error',
description: 'End time must be after start time',
variant: 'destructive',
});
return;
}
setSubmitting(true);
try {
const response = await fetch(`/api/admin/blocks/${editingBlock.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
courtId: editCourt === 'all' ? null : editCourt,
date: editDate,
startTime: editStartTime,
endTime: editEndTime,
reason: editReason.trim(),
}),
});
if (response.ok) {
toast({
title: 'Success',
description: 'Block updated successfully',
});
setEditingBlock(null);
fetchBlocks();
} else {
const data = await response.json();
toast({
title: 'Error',
description: data.error || 'Failed to update block',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error updating block:', error);
toast({
title: 'Error',
description: 'Failed to update block',
variant: 'destructive',
});
} finally {
setSubmitting(false);
}
};
const handleDeleteBlock = async (blockId: string) => {
try {
const response = await fetch(`/api/admin/blocks/${blockId}`, {
method: 'DELETE',
});
if (response.ok) {
toast({
title: 'Success',
description: 'Block removed successfully',
});
fetchBlocks();
} else {
const data = await response.json();
toast({
title: 'Error',
description: data.error || 'Failed to delete block',
variant: 'destructive',
});
}
} catch (error) {
console.error('Error deleting block:', error);
toast({
title: 'Error',
description: 'Failed to delete block',
variant: 'destructive',
});
}
};
// Get min date (today) and max date (e.g., 12 weeks from now)
const today = new Date();
const minDate = today.toISOString().split('T')[0];
const maxDate = new Date(today.getTime() + 84 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // 12 weeks
// Format date for display
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-IE', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
// Check if a block is in the past
const isBlockPast = (dateStr: string) => {
const blockDate = new Date(dateStr);
blockDate.setHours(23, 59, 59, 999);
return blockDate < new Date();
};
// Sort blocks by date
const sortedBlocks = [...blocks].sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateA.getTime() - dateB.getTime();
});
return (
<div className='space-y-6'>
{/* Create Block Form */}
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<CalendarX className='h-5 w-5' />
Block Courts/Hall
</CardTitle>
<CardDescription>
Block court availability for tournaments, AGM, maintenance, or other events. Blocked slots will
be visible to members but cannot be booked.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateBlock} className='space-y-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{/* Court Selection */}
<div className='space-y-2'>
<Label htmlFor='court'>Court</Label>
<Select value={selectedCourt} onValueChange={setSelectedCourt}>
<SelectTrigger>
<SelectValue placeholder='Select court' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Courts (Entire Hall)</SelectItem>
{courts.map((court) => (
<SelectItem key={court.id} value={court.id}>
{court.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Date Selection */}
<div className='space-y-2'>
<Label htmlFor='date'>Date</Label>
<Input
id='date'
type='date'
value={blockDate}
onChange={(e) => setBlockDate(e.target.value)}
min={minDate}
max={maxDate}
required
/>
</div>
{/* Reason */}
<div className='space-y-2 md:col-span-2 lg:col-span-1'>
<Label htmlFor='reason'>Reason</Label>
<Input
id='reason'
type='text'
placeholder='e.g., Tournament, AGM, Maintenance'
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={200}
required
/>
</div>
</div>
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
{/* Start Time */}
<div className='space-y-2'>
<Label htmlFor='startTime'>Start Time</Label>
<Select value={startTime} onValueChange={setStartTime}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeOptions.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* End Time */}
<div className='space-y-2'>
<Label htmlFor='endTime'>End Time</Label>
<Select value={endTime} onValueChange={setEndTime}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeOptions.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Create Announcement Toggle */}
<div className='col-span-2 flex items-center justify-between p-3 bg-muted/50 rounded-lg'>
<div className='flex items-center gap-2'>
<Bell className='h-4 w-4 text-muted-foreground' />
<Label htmlFor='createAnnouncement' className='text-sm cursor-pointer'>
Create announcement
</Label>
</div>
<Switch
id='createAnnouncement'
checked={createAnnouncement}
onCheckedChange={setCreateAnnouncement}
/>
</div>
</div>
{createAnnouncement && (
<p className='text-xs text-muted-foreground'>
An announcement will be created and automatically expire when the block ends.
</p>
)}
{/* Submit Button */}
<Button type='submit' disabled={submitting} className='w-full md:w-auto'>
<Plus className='h-4 w-4 mr-2' />
{submitting ? 'Creating...' : 'Create Block'}
</Button>
</form>
</CardContent>
</Card>
{/* Existing Blocks List */}
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Ban className='h-5 w-5' />
Scheduled Blocks
</CardTitle>
<CardDescription>
Upcoming and current court blocks. Past blocks are shown for reference.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className='text-center py-8 text-muted-foreground'>Loading blocks...</div>
) : sortedBlocks.length === 0 ? (
<div className='text-center py-8 text-muted-foreground'>
No blocks scheduled. Create a block above to reserve courts for events.
</div>
) : (
<>
{/* Desktop Table */}
<div className='hidden md:block overflow-x-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Court</TableHead>
<TableHead>Time</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Created By</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedBlocks.map((block) => (
<TableRow
key={block.id}
className={isBlockPast(block.date) ? 'opacity-50' : ''}
>
<TableCell>
<div className='flex items-center gap-2'>
<Calendar className='h-4 w-4 text-muted-foreground' />
{formatDate(block.date)}
{isBlockPast(block.date) && (
<Badge variant='outline' className='text-xs'>
Past
</Badge>
)}
</div>
</TableCell>
<TableCell>
{block.courtName ? (
<Badge variant='secondary'>{block.courtName}</Badge>
) : (
<Badge variant='destructive'>All Courts</Badge>
)}
</TableCell>
<TableCell>
{block.startTime} - {block.endTime}
</TableCell>
<TableCell className='max-w-[200px] truncate' title={block.reason}>
{block.reason}
</TableCell>
<TableCell>{block.creatorName}</TableCell>
<TableCell className='text-right'>
<div className='flex items-center justify-end gap-1'>
{!isBlockPast(block.date) && (
<Button
variant='ghost'
size='sm'
onClick={() => handleEditClick(block)}
>
<Edit className='h-4 w-4' />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
>
<Trash2 className='h-4 w-4' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Block?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the block for{' '}
{formatDate(block.date)} ({block.reason}).
Members will be able to book this time slot
again.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteBlock(block.id)}
>
Remove Block
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Mobile Cards */}
<div className='md:hidden space-y-4'>
{sortedBlocks.map((block) => (
<Card key={block.id} className={isBlockPast(block.date) ? 'opacity-50' : ''}>
<CardContent className='pt-4'>
<div className='flex justify-between items-start mb-2'>
<div className='flex items-center gap-2'>
<Calendar className='h-4 w-4 text-muted-foreground' />
<span className='font-medium'>{formatDate(block.date)}</span>
{isBlockPast(block.date) && (
<Badge variant='outline' className='text-xs'>
Past
</Badge>
)}
</div>
<div className='flex items-center gap-1'>
{!isBlockPast(block.date) && (
<Button
variant='ghost'
size='sm'
onClick={() => handleEditClick(block)}
>
<Edit className='h-4 w-4' />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
>
<Trash2 className='h-4 w-4' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Block?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the block for{' '}
{formatDate(block.date)} ({block.reason}).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteBlock(block.id)}
>
Remove Block
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className='space-y-1 text-sm'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground'>Court:</span>
{block.courtName ? (
<Badge variant='secondary'>{block.courtName}</Badge>
) : (
<Badge variant='destructive'>All Courts</Badge>
)}
</div>
<div>
<span className='text-muted-foreground'>Time: </span>
{block.startTime} - {block.endTime}
</div>
<div>
<span className='text-muted-foreground'>Reason: </span>
{block.reason}
</div>
<div className='text-xs text-muted-foreground'>
Created by {block.creatorName}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</>
)}
</CardContent>
</Card>
{/* Edit Block Dialog */}
<Dialog open={!!editingBlock} onOpenChange={(open) => !open && setEditingBlock(null)}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Edit Block</DialogTitle>
<DialogDescription>Modify the court closure details.</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='edit-court'>Court</Label>
<Select value={editCourt || 'all'} onValueChange={setEditCourt}>
<SelectTrigger>
<SelectValue placeholder='Select a court' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Courts</SelectItem>
{courts.map((court) => (
<SelectItem key={court.id} value={court.id.toString()}>
{court.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit-date'>Date</Label>
<Input
id='edit-date'
type='date'
value={editDate}
onChange={(e) => setEditDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-2'>
<Label htmlFor='edit-start-time'>Start Time</Label>
<Select value={editStartTime} onValueChange={setEditStartTime}>
<SelectTrigger>
<SelectValue placeholder='Start time' />
</SelectTrigger>
<SelectContent>
{timeOptions.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit-end-time'>End Time</Label>
<Select value={editEndTime} onValueChange={setEditEndTime}>
<SelectTrigger>
<SelectValue placeholder='End time' />
</SelectTrigger>
<SelectContent>
{timeOptions.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit-reason'>Reason</Label>
<Input
id='edit-reason'
type='text'
value={editReason}
onChange={(e) => setEditReason(e.target.value)}
placeholder='e.g., Tournament, Maintenance'
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setEditingBlock(null)}>
Cancel
</Button>
<Button onClick={handleEditSave} disabled={submitting}>
{submitting ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}