Compare commits

..

4 Commits

Author SHA1 Message Date
mikicv f45a6f46a5 Merge pull request 'feat: implement admin blocks management feature' (#2) from feat/booking-blocks/court-blocking into main
Reviewed-on: #2
2025-12-29 17:09:00 +00:00
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
mikicv 54240a2cfd Merge pull request 'Deps: update dependencies and script' (#1) from deps/security/upgrade-modules into main
Reviewed-on: #1
2025-12-29 17:02:32 +00:00
mikicvi ab1ac4427a chore: update database command and next.js version
- Changed the database migration command from "db:migrate" to "db:generate" for SQLite.
- Updated the Next.js version from 15.5.3 to 15.5.7 in package.json.
2025-12-29 16:59:57 +00:00
15 changed files with 2220 additions and 394 deletions
+128
View File
@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
const { courtId, date, startTime, endTime, reason } = await request.json();
if (!date || !startTime || !endTime || !reason) {
return NextResponse.json(
{ error: 'Missing required fields: date, startTime, endTime, reason' },
{ status: 400 }
);
}
// Check if block exists
const existingBlock = await db.select().from(courtBlocks).where(eq(courtBlocks.id, id)).limit(1);
if (existingBlock.length === 0) {
return NextResponse.json({ error: 'Block not found' }, { status: 404 });
}
// Validate date is not in the past
const blockDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (blockDate < today) {
return NextResponse.json({ error: 'Cannot set blocks for past dates' }, { status: 400 });
}
// If courtId is provided, verify it exists
if (courtId) {
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
if (court.length === 0) {
return NextResponse.json({ error: 'Court not found' }, { status: 400 });
}
}
// Update the block
const [updatedBlock] = await db
.update(courtBlocks)
.set({
courtId: courtId || null,
date,
startTime,
endTime,
reason,
})
.where(eq(courtBlocks.id, id))
.returning();
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_UPDATE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: id,
details: {
courtId: courtId || 'all',
date,
startTime,
endTime,
reason,
},
});
return NextResponse.json({
block: updatedBlock,
message: 'Block updated successfully',
});
} catch (error) {
console.error('Error updating block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
// Check if block exists
const existingBlock = await db.select().from(courtBlocks).where(eq(courtBlocks.id, id)).limit(1);
if (existingBlock.length === 0) {
return NextResponse.json({ error: 'Block not found' }, { status: 404 });
}
const block = existingBlock[0];
// Delete the block
await db.delete(courtBlocks).where(eq(courtBlocks.id, id));
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_DELETE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: id,
details: {
courtId: block.courtId || 'all',
date: block.date,
startTime: block.startTime,
endTime: block.endTime,
reason: block.reason,
},
});
return NextResponse.json({ message: 'Block deleted successfully' });
} catch (error) {
console.error('Error deleting block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+176
View File
@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts, users, announcements } from '@/lib/db/schema';
import { eq, gte, asc } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const includeExpired = searchParams.get('includeExpired') === 'true';
// Get today's date
const today = new Date().toISOString().split('T')[0];
// Fetch blocks with court and creator info
let query = db
.select({
id: courtBlocks.id,
courtId: courtBlocks.courtId,
date: courtBlocks.date,
startTime: courtBlocks.startTime,
endTime: courtBlocks.endTime,
reason: courtBlocks.reason,
createdBy: courtBlocks.createdBy,
createdAt: courtBlocks.createdAt,
court: {
id: courts.id,
name: courts.name,
},
creator: {
id: users.id,
name: users.name,
surname: users.surname,
},
})
.from(courtBlocks)
.leftJoin(courts, eq(courtBlocks.courtId, courts.id))
.innerJoin(users, eq(courtBlocks.createdBy, users.id));
const rawBlocks = includeExpired
? await query.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime))
: await query
.where(gte(courtBlocks.date, today))
.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime));
// Transform to flat structure for frontend
const blocks = rawBlocks.map((block) => ({
id: block.id,
courtId: block.courtId,
courtName: block.court?.name || null,
date: block.date,
startTime: block.startTime,
endTime: block.endTime,
reason: block.reason,
createdBy: block.createdBy,
creatorName: block.creator ? `${block.creator.name} ${block.creator.surname}` : 'Unknown',
createdAt: block.createdAt,
}));
return NextResponse.json({ blocks });
} catch (error) {
console.error('Error fetching blocks:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { courtId, date, startTime, endTime, reason, createAnnouncement } = await request.json();
if (!date || !startTime || !endTime || !reason) {
return NextResponse.json(
{ error: 'Missing required fields: date, startTime, endTime, reason' },
{ status: 400 }
);
}
// Validate date is not in the past
const blockDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (blockDate < today) {
return NextResponse.json({ error: 'Cannot create blocks for past dates' }, { status: 400 });
}
// Get court name if courtId is provided
let courtName = 'All Courts';
if (courtId) {
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
if (court.length === 0) {
return NextResponse.json({ error: 'Court not found' }, { status: 400 });
}
courtName = court[0].name;
}
// Create the block
const blockId = crypto.randomUUID();
const [newBlock] = await db
.insert(courtBlocks)
.values({
id: blockId,
courtId: courtId || null, // null means all courts
date,
startTime,
endTime,
reason,
createdBy: session.userId,
createdAt: new Date(),
})
.returning();
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_CREATE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: blockId,
details: {
courtId: courtId || 'all',
date,
startTime,
endTime,
reason,
},
});
// Optionally create an announcement that expires when the block ends
let announcementCreated = false;
if (createAnnouncement) {
const formattedDate = new Date(date).toLocaleDateString('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
// Set expiry to end of block day at the end time
const expiryDate = new Date(date);
const [endHour] = endTime.split(':').map(Number);
expiryDate.setHours(endHour, 0, 0, 0);
await db.insert(announcements).values({
id: crypto.randomUUID(),
title: `${reason} - ${formattedDate}`,
content: `${courtName} will be unavailable on ${formattedDate} from ${startTime} to ${endTime} due to: ${reason}`,
priority: 'high',
expiresAt: expiryDate,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
announcementCreated = true;
}
return NextResponse.json({
block: newBlock,
announcementCreated,
message: 'Block created successfully',
});
} catch (error) {
console.error('Error creating block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts } from '@/lib/db/schema';
import { eq, gte, and, lte, asc } from 'drizzle-orm';
import { getSession } from '@/lib/session';
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
// Default to today if no start date provided
const today = new Date().toISOString().split('T')[0];
const queryStartDate = startDate || today;
// Default to 60 days ahead if no end date provided (8 weeks + buffer)
const defaultEndDate = new Date();
defaultEndDate.setDate(defaultEndDate.getDate() + 60);
const queryEndDate = endDate || defaultEndDate.toISOString().split('T')[0];
// Fetch blocks with court info for the date range
const blocks = await db
.select({
id: courtBlocks.id,
courtId: courtBlocks.courtId,
date: courtBlocks.date,
startTime: courtBlocks.startTime,
endTime: courtBlocks.endTime,
reason: courtBlocks.reason,
court: {
id: courts.id,
name: courts.name,
},
})
.from(courtBlocks)
.leftJoin(courts, eq(courtBlocks.courtId, courts.id))
.where(and(gte(courtBlocks.date, queryStartDate), lte(courtBlocks.date, queryEndDate)))
.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime));
return NextResponse.json({ blocks });
} catch (error) {
console.error('Error fetching blocks:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+53 -6
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { bookings, courts, timeSlots, settings, metrics } from '@/lib/db/schema'; import { bookings, courts, timeSlots, settings, metrics, courtBlocks } from '@/lib/db/schema';
import { eq, and, gte, asc } from 'drizzle-orm'; import { eq, and, gte, asc, or, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/session'; import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger'; import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
@@ -65,6 +65,9 @@ export async function POST(request: NextRequest) {
const [hours, minutes] = timeSlot.split(':').map(Number); const [hours, minutes] = timeSlot.split(':').map(Number);
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
// Check if user is admin (for bypassing certain restrictions)
const isAdmin = session.role === 'admin';
// Validate booking date is not in the past // Validate booking date is not in the past
const bookingDate = new Date(date); const bookingDate = new Date(date);
const today = new Date(); const today = new Date();
@@ -90,6 +93,45 @@ export async function POST(request: NextRequest) {
); );
} }
// CHECK FOR BLOCKS - applies to everyone including admins
// A block prevents any booking on that court/time (admins should remove the block first)
const requestedHour = parseInt(startTime.split(':')[0]);
const activeBlocks = await db
.select()
.from(courtBlocks)
.where(
and(
eq(courtBlocks.date, date),
or(
eq(courtBlocks.courtId, courtId), // Block for this specific court
isNull(courtBlocks.courtId) // Block for all courts
)
)
);
// Check if any block covers this time slot
const isBlockedSlot = activeBlocks.some((block) => {
const blockStartHour = parseInt(block.startTime.split(':')[0]);
const blockEndHour = parseInt(block.endTime.split(':')[0]);
return requestedHour >= blockStartHour && requestedHour < blockEndHour;
});
if (isBlockedSlot) {
const blockingBlock = activeBlocks.find((block) => {
const blockStartHour = parseInt(block.startTime.split(':')[0]);
const blockEndHour = parseInt(block.endTime.split(':')[0]);
return requestedHour >= blockStartHour && requestedHour < blockEndHour;
});
return NextResponse.json(
{
error: `This slot is blocked: ${
blockingBlock?.reason || 'Court unavailable'
}. Please choose a different time.`,
},
{ status: 400 }
);
}
// CRITICAL: Validate that booking is allowed for this day and time // CRITICAL: Validate that booking is allowed for this day and time
const dayOfWeek = bookingDate.getDay(); const dayOfWeek = bookingDate.getDay();
const availableTimeSlots = await db const availableTimeSlots = await db
@@ -97,8 +139,8 @@ export async function POST(request: NextRequest) {
.from(timeSlots) .from(timeSlots)
.where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true))); .where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
// Check if any time slots are configured for this day // Check if any time slots are configured for this day (admins can bypass if needed)
if (availableTimeSlots.length === 0) { if (availableTimeSlots.length === 0 && !isAdmin) {
return NextResponse.json( return NextResponse.json(
{ {
error: `No bookings are allowed on ${ error: `No bookings are allowed on ${
@@ -110,7 +152,8 @@ export async function POST(request: NextRequest) {
} }
// Check if the requested time slot is within any of the allowed time ranges // Check if the requested time slot is within any of the allowed time ranges
const requestedHour = parseInt(startTime.split(':')[0]); // Admins can bypass time slot restrictions
if (!isAdmin) {
const isTimeSlotValid = availableTimeSlots.some((slot) => { const isTimeSlotValid = availableTimeSlots.some((slot) => {
const slotStartHour = parseInt(slot.startTime.split(':')[0]); const slotStartHour = parseInt(slot.startTime.split(':')[0]);
const slotEndHour = parseInt(slot.endTime.split(':')[0]); const slotEndHour = parseInt(slot.endTime.split(':')[0]);
@@ -128,15 +171,18 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
}
// Check booking restrictions per user per hour per day // Check booking restrictions per user per hour per day
// Admins bypass this restriction
if (!isAdmin) {
const maxBookingsSetting = await db const maxBookingsSetting = await db
.select() .select()
.from(settings) .from(settings)
.where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day')) .where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day'))
.limit(1); .limit(1);
const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1; // Default to 1 if setting not found const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1;
// Count user's existing bookings for this hour on this day // Count user's existing bookings for this hour on this day
const userBookingsThisHour = await db const userBookingsThisHour = await db
@@ -159,6 +205,7 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
}
// Check if slot is already booked // Check if slot is already booked
const existingBooking = await db const existingBooking = await db
+753
View File
@@ -0,0 +1,753 @@
'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>
);
}
+33 -7
View File
@@ -19,9 +19,10 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw } from 'lucide-react'; import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw, Ban } from 'lucide-react';
import { AdminBlocksManagement } from './AdminBlocksManagement';
interface Court { interface Court {
id: string; id: string;
@@ -219,7 +220,7 @@ export function AdminCourtManagement() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Court Management</CardTitle> <CardTitle>Courts & Closures</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='space-y-4'> <div className='space-y-4'>
@@ -236,6 +237,20 @@ export function AdminCourtManagement() {
} }
return ( 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> <Card>
<CardHeader className='flex flex-row items-center justify-between'> <CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2'> <CardTitle className='flex items-center gap-2'>
@@ -256,7 +271,9 @@ export function AdminCourtManagement() {
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{editingCourt ? 'Edit Court' : 'Create New Court'}</DialogTitle> <DialogTitle>
{editingCourt ? 'Edit Court' : 'Create New Court'}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className='space-y-4'> <form onSubmit={handleSubmit} className='space-y-4'>
<div> <div>
@@ -319,7 +336,8 @@ export function AdminCourtManagement() {
<div> <div>
<h3 className='font-medium'>{court.name}</h3> <h3 className='font-medium'>{court.name}</h3>
<p className='text-sm text-gray-500'> <p className='text-sm text-gray-500'>
Created {new Date(court.createdAt).toLocaleDateString('en-IE')} Created{' '}
{new Date(court.createdAt).toLocaleDateString('en-IE')}
</p> </p>
</div> </div>
</div> </div>
@@ -366,8 +384,9 @@ export function AdminCourtManagement() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? Are you sure you want to delete{' '}
This action cannot be undone. {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? This action cannot
be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -382,5 +401,12 @@ export function AdminCourtManagement() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</Card> </Card>
</TabsContent>
<TabsContent value='closures'>
<AdminBlocksManagement />
</TabsContent>
</Tabs>
</div>
); );
} }
+1 -1
View File
@@ -274,7 +274,7 @@ export function AdminDashboard() {
</TabsContent> </TabsContent>
<TabsContent value='courts'> <TabsContent value='courts'>
<AdminCourtManagement /> <AdminCourtManagement />
</TabsContent>{' '} </TabsContent>
<TabsContent value='settings'> <TabsContent value='settings'>
<div className='space-y-6'> <div className='space-y-6'>
<AdminSettingsManagement /> <AdminSettingsManagement />
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight, Users, User } from 'lucide-react'; import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight, Users, User, Ban } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
interface Court { interface Court {
@@ -40,6 +40,8 @@ interface BookingSlot {
bookingId?: string; bookingId?: string;
bookedBy?: string; bookedBy?: string;
partner?: string; partner?: string;
blocked?: boolean;
blockReason?: string;
} }
interface TimeSlot { interface TimeSlot {
@@ -50,6 +52,15 @@ interface TimeSlot {
isActive: boolean; isActive: boolean;
} }
interface CourtBlock {
id: string;
courtId: string | null;
date: string;
startTime: string;
endTime: string;
reason: string;
}
interface Settings { interface Settings {
booking_window_days: string; booking_window_days: string;
booking_start_time: string; booking_start_time: string;
@@ -63,6 +74,7 @@ export function EnhancedBookingCalendar() {
const [bookings, setBookings] = useState<Booking[]>([]); const [bookings, setBookings] = useState<Booking[]>([]);
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]); const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]); const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
const [blocks, setBlocks] = useState<CourtBlock[]>([]);
const [settings, setSettings] = useState<Settings | null>(null); const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [partnerName, setPartnerName] = useState(''); const [partnerName, setPartnerName] = useState('');
@@ -75,6 +87,7 @@ export function EnhancedBookingCalendar() {
fetchSettings(); fetchSettings();
fetchCourts(); fetchCourts();
fetchTimeSlots(); fetchTimeSlots();
fetchBlocks();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -156,6 +169,20 @@ export function EnhancedBookingCalendar() {
} }
}; };
// Fetch court blocks (closures)
const fetchBlocks = async () => {
try {
const response = await fetch('/api/blocks');
if (response.ok) {
const data = await response.json();
setBlocks(data.blocks || []);
}
} catch (error) {
console.error('Error fetching blocks:', error);
// If blocks fetch fails, just proceed without block data
}
};
const fetchBookings = async () => { const fetchBookings = async () => {
try { try {
const dateStr = selectedDate.toISOString().split('T')[0]; const dateStr = selectedDate.toISOString().split('T')[0];
@@ -236,6 +263,9 @@ export function EnhancedBookingCalendar() {
const timeSlots = generateTimeSlots(); const timeSlots = generateTimeSlots();
const slots: BookingSlot[] = []; const slots: BookingSlot[] = [];
// Get blocks for the selected date
const dateBlocks = blocks.filter((block) => block.date === dateStr);
courts.forEach((court) => { courts.forEach((court) => {
timeSlots.forEach((time) => { timeSlots.forEach((time) => {
const existingBooking = existingBookings.find( const existingBooking = existingBookings.find(
@@ -246,6 +276,17 @@ export function EnhancedBookingCalendar() {
booking.status === 'active' booking.status === 'active'
); );
// Check if this time slot is blocked
const slotHour = parseInt(time.split(':')[0]);
const blockingBlock = dateBlocks.find((block) => {
const blockStartHour = parseInt(block.startTime.split(':')[0]);
const blockEndHour = parseInt(block.endTime.split(':')[0]);
const isTimeInBlock = slotHour >= blockStartHour && slotHour < blockEndHour;
// Block applies if it's for this specific court or for all courts (courtId null/undefined/empty)
const appliesToCourt = !block.courtId || block.courtId === court.id;
return isTimeInBlock && appliesToCourt;
});
const bookedBy = existingBooking?.user const bookedBy = existingBooking?.user
? `${existingBooking.user.name} ${existingBooking.user.surname}` ? `${existingBooking.user.name} ${existingBooking.user.surname}`
: undefined; : undefined;
@@ -256,10 +297,12 @@ export function EnhancedBookingCalendar() {
time, time,
courtId: court.id, courtId: court.id,
courtName: court.name, courtName: court.name,
available: !existingBooking, available: !existingBooking && !blockingBlock,
bookingId: existingBooking?.id, bookingId: existingBooking?.id,
bookedBy, bookedBy,
partner, partner,
blocked: !!blockingBlock,
blockReason: blockingBlock?.reason,
}); });
}); });
}); });
@@ -302,6 +345,15 @@ export function EnhancedBookingCalendar() {
}; };
const handleSlotClick = (slot: BookingSlot) => { const handleSlotClick = (slot: BookingSlot) => {
if (slot.blocked) {
toast({
title: 'Slot Blocked',
description: slot.blockReason || 'This slot is blocked for an event',
variant: 'destructive',
});
return;
}
if (!slot.available) return; if (!slot.available) return;
// Double-check that this day is actually bookable // Double-check that this day is actually bookable
@@ -570,7 +622,9 @@ export function EnhancedBookingCalendar() {
<div <div
key={`${slot.courtId}-${slot.time}`} key={`${slot.courtId}-${slot.time}`}
className={`p-3 border rounded-lg transition-all duration-200 ${ className={`p-3 border rounded-lg transition-all duration-200 ${
slot.available slot.blocked
? 'border-orange-300 bg-orange-50 cursor-not-allowed dark:border-orange-700 dark:bg-orange-950/50'
: slot.available
? 'border-green-200 bg-green-50 hover:bg-green-100 hover:shadow-sm cursor-pointer dark:border-green-700 dark:bg-green-950 dark:hover:bg-green-900' ? 'border-green-200 bg-green-50 hover:bg-green-100 hover:shadow-sm cursor-pointer dark:border-green-700 dark:bg-green-950 dark:hover:bg-green-900'
: 'border-muted bg-muted/50 cursor-not-allowed opacity-75 hover:opacity-100' : 'border-muted bg-muted/50 cursor-not-allowed opacity-75 hover:opacity-100'
}`} }`}
@@ -582,7 +636,15 @@ export function EnhancedBookingCalendar() {
<MapPin className='h-4 w-4' /> <MapPin className='h-4 w-4' />
{slot.courtName} {slot.courtName}
</div> </div>
{!slot.available && slot.bookedBy && ( {slot.blocked && (
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
<Ban className='h-3 w-3' />
{slot.blockReason || 'Blocked'}
</div>
)}
{!slot.blocked &&
!slot.available &&
slot.bookedBy && (
<div className='space-y-1'> <div className='space-y-1'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'> <div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Users className='h-3 w-3' /> <Users className='h-3 w-3' />
@@ -596,7 +658,9 @@ export function EnhancedBookingCalendar() {
)} )}
</div> </div>
)} )}
{!slot.available && !slot.bookedBy && ( {!slot.blocked &&
!slot.available &&
!slot.bookedBy && (
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
Already booked Already booked
</div> </div>
@@ -604,17 +668,27 @@ export function EnhancedBookingCalendar() {
</div> </div>
<Button <Button
size='sm' size='sm'
disabled={!slot.available} disabled={!slot.available || slot.blocked}
variant={ variant={
slot.available ? 'default' : 'secondary' slot.blocked
? 'outline'
: slot.available
? 'default'
: 'secondary'
} }
className={ className={
slot.available slot.blocked
? 'border-orange-400 text-orange-600 dark:border-orange-600 dark:text-orange-400 cursor-not-allowed'
: slot.available
? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 border-0' ? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 border-0'
: 'opacity-50 cursor-not-allowed' : 'opacity-50 cursor-not-allowed'
} }
> >
{slot.available ? 'Book' : 'Booked'} {slot.blocked
? 'Blocked'
: slot.available
? 'Book'
: 'Booked'}
</Button> </Button>
</div> </div>
</div> </div>
+6
View File
@@ -85,6 +85,11 @@ export const ACTIONS = {
TIME_SLOT_UPDATE: 'update_time_slot', TIME_SLOT_UPDATE: 'update_time_slot',
TIME_SLOT_DELETE: 'delete_time_slot', TIME_SLOT_DELETE: 'delete_time_slot',
// Court block actions
BLOCK_CREATE: 'create_block',
BLOCK_UPDATE: 'update_block',
BLOCK_DELETE: 'delete_block',
// System actions // System actions
SYSTEM_START: 'system_start', SYSTEM_START: 'system_start',
SYSTEM_ERROR: 'system_error', SYSTEM_ERROR: 'system_error',
@@ -97,5 +102,6 @@ export const ENTITY_TYPES = {
ANNOUNCEMENT: 'announcement', ANNOUNCEMENT: 'announcement',
SETTINGS: 'settings', SETTINGS: 'settings',
TIME_SLOT: 'time_slot', TIME_SLOT: 'time_slot',
COURT_BLOCK: 'court_block',
SYSTEM: 'system', SYSTEM: 'system',
} as const; } as const;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `court_blocks` (
`id` text PRIMARY KEY NOT NULL,
`court_id` text,
`date` text NOT NULL,
`start_time` text NOT NULL,
`end_time` text NOT NULL,
`reason` text NOT NULL,
`created_by` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`court_id`) REFERENCES `courts`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE users ADD `theme_preference` text DEFAULT 'system' NOT NULL;
+649
View File
@@ -0,0 +1,649 @@
{
"version": "5",
"dialect": "sqlite",
"id": "cb371f1e-1bfd-4fa2-be7c-a0375bbc11bc",
"prevId": "b6ff7034-4299-4b61-8a16-3b46eae7b4ef",
"tables": {
"activity_logs": {
"name": "activity_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"activity_logs_user_id_users_id_fk": {
"name": "activity_logs_user_id_users_id_fk",
"tableFrom": "activity_logs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"announcements": {
"name": "announcements",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"bookings": {
"name": "bookings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"court_id": {
"name": "court_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"bookings_user_id_users_id_fk": {
"name": "bookings_user_id_users_id_fk",
"tableFrom": "bookings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"bookings_court_id_courts_id_fk": {
"name": "bookings_court_id_courts_id_fk",
"tableFrom": "bookings",
"tableTo": "courts",
"columnsFrom": [
"court_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"court_blocks": {
"name": "court_blocks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"court_id": {
"name": "court_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"court_blocks_court_id_courts_id_fk": {
"name": "court_blocks_court_id_courts_id_fk",
"tableFrom": "court_blocks",
"tableTo": "courts",
"columnsFrom": [
"court_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"court_blocks_created_by_users_id_fk": {
"name": "court_blocks_created_by_users_id_fk",
"tableFrom": "court_blocks",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"courts": {
"name": "courts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"metrics": {
"name": "metrics",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"metric_type": {
"name": "metric_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"period": {
"name": "period",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"settings_key_unique": {
"name": "settings_key_unique",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"time_slots": {
"name": "time_slots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_of_week": {
"name": "day_of_week",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"surname": {
"name": "surname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"theme_preference": {
"name": "theme_preference",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'system'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1758824962110, "when": 1758824962110,
"tag": "0001_slimy_starjammers", "tag": "0001_slimy_starjammers",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1766916904651,
"tag": "0002_thick_makkari",
"breakpoints": true
} }
] ]
} }
+18
View File
@@ -104,6 +104,20 @@ export const metrics = sqliteTable('metrics', {
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}); });
// Court blocks table for admin-managed closures (tournaments, maintenance, etc.)
export const courtBlocks = sqliteTable('court_blocks', {
id: text('id').primaryKey(),
courtId: text('court_id').references(() => courts.id, { onDelete: 'cascade' }), // NULL means all courts
date: text('date').notNull(), // Format: "YYYY-MM-DD"
startTime: text('start_time').notNull(), // Format: "HH:MM"
endTime: text('end_time').notNull(), // Format: "HH:MM"
reason: text('reason').notNull(), // e.g., "Tournament", "AGM", "Maintenance"
createdBy: text('created_by')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
// Zod schemas for validation // Zod schemas for validation
export const insertUserSchema = createInsertSchema(users); export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users); export const selectUserSchema = createSelectSchema(users);
@@ -121,6 +135,8 @@ export const insertActivityLogSchema = createInsertSchema(activityLogs);
export const selectActivityLogSchema = createSelectSchema(activityLogs); export const selectActivityLogSchema = createSelectSchema(activityLogs);
export const insertMetricSchema = createInsertSchema(metrics); export const insertMetricSchema = createInsertSchema(metrics);
export const selectMetricSchema = createSelectSchema(metrics); export const selectMetricSchema = createSelectSchema(metrics);
export const insertCourtBlockSchema = createInsertSchema(courtBlocks);
export const selectCourtBlockSchema = createSelectSchema(courtBlocks);
// Types // Types
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect;
@@ -139,3 +155,5 @@ export type ActivityLog = typeof activityLogs.$inferSelect;
export type NewActivityLog = typeof activityLogs.$inferInsert; export type NewActivityLog = typeof activityLogs.$inferInsert;
export type Metric = typeof metrics.$inferSelect; export type Metric = typeof metrics.$inferSelect;
export type NewMetric = typeof metrics.$inferInsert; export type NewMetric = typeof metrics.$inferInsert;
export type CourtBlock = typeof courtBlocks.$inferSelect;
export type NewCourtBlock = typeof courtBlocks.$inferInsert;
+54 -177
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -8,7 +8,7 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"db:push": "drizzle-kit push:sqlite", "db:push": "drizzle-kit push:sqlite",
"db:migrate": "drizzle-kit migrate", "db:generate": "drizzle-kit generate:sqlite",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:init": "mkdir -p data && npm run db:push", "db:init": "mkdir -p data && npm run db:push",
"db:setup": "tsx scripts/setup-database.ts", "db:setup": "tsx scripts/setup-database.ts",
@@ -48,7 +48,7 @@
"jose": "^6.1.0", "jose": "^6.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next": "^15.5.3", "next": "^15.5.7",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nodemailer": "^6.9.7", "nodemailer": "^6.9.7",
"react": "^19.1.1", "react": "^19.1.1",