From 40c56770a23769282f45c4061563fbde0350bc7e Mon Sep 17 00:00:00 2001 From: mikicvi <88291034+mikicvi@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:04:16 +0000 Subject: [PATCH] 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. --- app/api/admin/blocks/[id]/route.ts | 128 +++ app/api/admin/blocks/route.ts | 176 ++++ app/api/blocks/route.ts | 51 ++ app/api/bookings/route.ts | 137 ++-- components/admin/AdminBlocksManagement.tsx | 753 ++++++++++++++++++ components/admin/AdminCourtManagement.tsx | 314 ++++---- components/admin/admin-dashboard.tsx | 2 +- .../booking/enhanced-booking-calendar.tsx | 124 ++- lib/activity-logger.ts | 6 + lib/db/migrations/0002_thick_makkari.sql | 14 + lib/db/migrations/meta/0002_snapshot.json | 649 +++++++++++++++ lib/db/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 18 + 13 files changed, 2164 insertions(+), 215 deletions(-) create mode 100644 app/api/admin/blocks/[id]/route.ts create mode 100644 app/api/admin/blocks/route.ts create mode 100644 app/api/blocks/route.ts create mode 100644 components/admin/AdminBlocksManagement.tsx create mode 100644 lib/db/migrations/0002_thick_makkari.sql create mode 100644 lib/db/migrations/meta/0002_snapshot.json diff --git a/app/api/admin/blocks/[id]/route.ts b/app/api/admin/blocks/[id]/route.ts new file mode 100644 index 0000000..9cc4a33 --- /dev/null +++ b/app/api/admin/blocks/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/blocks/route.ts b/app/api/admin/blocks/route.ts new file mode 100644 index 0000000..0986683 --- /dev/null +++ b/app/api/admin/blocks/route.ts @@ -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 }); + } +} diff --git a/app/api/blocks/route.ts b/app/api/blocks/route.ts new file mode 100644 index 0000000..d467942 --- /dev/null +++ b/app/api/blocks/route.ts @@ -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 }); + } +} diff --git a/app/api/bookings/route.ts b/app/api/bookings/route.ts index 9dc0406..24badce 100644 --- a/app/api/bookings/route.ts +++ b/app/api/bookings/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { bookings, courts, timeSlots, settings, metrics } from '@/lib/db/schema'; -import { eq, and, gte, asc } from 'drizzle-orm'; +import { bookings, courts, timeSlots, settings, metrics, courtBlocks } from '@/lib/db/schema'; +import { eq, and, gte, asc, or, isNull } from 'drizzle-orm'; import { getSession } from '@/lib/session'; 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 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 const bookingDate = new Date(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 const dayOfWeek = bookingDate.getDay(); const availableTimeSlots = await db @@ -97,8 +139,8 @@ export async function POST(request: NextRequest) { .from(timeSlots) .where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true))); - // Check if any time slots are configured for this day - if (availableTimeSlots.length === 0) { + // Check if any time slots are configured for this day (admins can bypass if needed) + if (availableTimeSlots.length === 0 && !isAdmin) { return NextResponse.json( { error: `No bookings are allowed on ${ @@ -110,54 +152,59 @@ export async function POST(request: NextRequest) { } // Check if the requested time slot is within any of the allowed time ranges - const requestedHour = parseInt(startTime.split(':')[0]); - const isTimeSlotValid = availableTimeSlots.some((slot) => { - const slotStartHour = parseInt(slot.startTime.split(':')[0]); - const slotEndHour = parseInt(slot.endTime.split(':')[0]); - return requestedHour >= slotStartHour && requestedHour < slotEndHour; - }); + // Admins can bypass time slot restrictions + if (!isAdmin) { + const isTimeSlotValid = availableTimeSlots.some((slot) => { + const slotStartHour = parseInt(slot.startTime.split(':')[0]); + const slotEndHour = parseInt(slot.endTime.split(':')[0]); + return requestedHour >= slotStartHour && requestedHour < slotEndHour; + }); - if (!isTimeSlotValid) { - const allowedRanges = availableTimeSlots.map((slot) => `${slot.startTime}-${slot.endTime}`).join(', '); - return NextResponse.json( - { - error: `Time slot ${startTime} is not available on ${ - ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek] - }s. Available times: ${allowedRanges}`, - }, - { status: 400 } - ); + if (!isTimeSlotValid) { + const allowedRanges = availableTimeSlots.map((slot) => `${slot.startTime}-${slot.endTime}`).join(', '); + return NextResponse.json( + { + error: `Time slot ${startTime} is not available on ${ + ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek] + }s. Available times: ${allowedRanges}`, + }, + { status: 400 } + ); + } } // Check booking restrictions per user per hour per day - const maxBookingsSetting = await db - .select() - .from(settings) - .where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day')) - .limit(1); + // Admins bypass this restriction + if (!isAdmin) { + const maxBookingsSetting = await db + .select() + .from(settings) + .where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day')) + .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 - const userBookingsThisHour = await db - .select() - .from(bookings) - .where( - and( - eq(bookings.userId, session.userId), - eq(bookings.date, date), - eq(bookings.startTime, startTime), - eq(bookings.status, 'active') - ) - ); + // Count user's existing bookings for this hour on this day + const userBookingsThisHour = await db + .select() + .from(bookings) + .where( + and( + eq(bookings.userId, session.userId), + eq(bookings.date, date), + eq(bookings.startTime, startTime), + eq(bookings.status, 'active') + ) + ); - if (userBookingsThisHour.length >= maxBookingsPerHour) { - return NextResponse.json( - { - error: `You have reached the maximum limit of ${maxBookingsPerHour} booking(s) per hour. You already have ${userBookingsThisHour.length} booking(s) at ${startTime} on this date.`, - }, - { status: 400 } - ); + if (userBookingsThisHour.length >= maxBookingsPerHour) { + return NextResponse.json( + { + error: `You have reached the maximum limit of ${maxBookingsPerHour} booking(s) per hour. You already have ${userBookingsThisHour.length} booking(s) at ${startTime} on this date.`, + }, + { status: 400 } + ); + } } // Check if slot is already booked diff --git a/components/admin/AdminBlocksManagement.tsx b/components/admin/AdminBlocksManagement.tsx new file mode 100644 index 0000000..abd6629 --- /dev/null +++ b/components/admin/AdminBlocksManagement.tsx @@ -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([]); + const [courts, setCourts] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + + // Form state for creating + const [selectedCourt, setSelectedCourt] = useState('all'); + const [blockDate, setBlockDate] = useState(''); + const [startTime, setStartTime] = useState('18:00'); + const [endTime, setEndTime] = useState('23:00'); + const [reason, setReason] = useState(''); + const [createAnnouncement, setCreateAnnouncement] = useState(true); + + // Edit modal state + const [editingBlock, setEditingBlock] = useState(null); + const [editCourt, setEditCourt] = useState('all'); + const [editDate, setEditDate] = useState(''); + const [editStartTime, setEditStartTime] = useState(''); + const [editEndTime, setEditEndTime] = useState(''); + const [editReason, setEditReason] = useState(''); + + // 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 ( +
+ {/* Create Block Form */} + + + + + Block Courts/Hall + + + Block court availability for tournaments, AGM, maintenance, or other events. Blocked slots will + be visible to members but cannot be booked. + + + +
+
+ {/* Court Selection */} +
+ + +
+ + {/* Date Selection */} +
+ + setBlockDate(e.target.value)} + min={minDate} + max={maxDate} + required + /> +
+ + {/* Reason */} +
+ + setReason(e.target.value)} + maxLength={200} + required + /> +
+
+ +
+ {/* Start Time */} +
+ + +
+ + {/* End Time */} +
+ + +
+ + {/* Create Announcement Toggle */} +
+
+ + +
+ +
+
+ + {createAnnouncement && ( +

+ An announcement will be created and automatically expire when the block ends. +

+ )} + + {/* Submit Button */} + +
+
+
+ + {/* Existing Blocks List */} + + + + + Scheduled Blocks + + + Upcoming and current court blocks. Past blocks are shown for reference. + + + + {loading ? ( +
Loading blocks...
+ ) : sortedBlocks.length === 0 ? ( +
+ No blocks scheduled. Create a block above to reserve courts for events. +
+ ) : ( + <> + {/* Desktop Table */} +
+ + + + Date + Court + Time + Reason + Created By + Actions + + + + {sortedBlocks.map((block) => ( + + +
+ + {formatDate(block.date)} + {isBlockPast(block.date) && ( + + Past + + )} +
+
+ + {block.courtName ? ( + {block.courtName} + ) : ( + All Courts + )} + + + {block.startTime} - {block.endTime} + + + {block.reason} + + {block.creatorName} + +
+ {!isBlockPast(block.date) && ( + + )} + + + + + + + Remove Block? + + This will remove the block for{' '} + {formatDate(block.date)} ({block.reason}). + Members will be able to book this time slot + again. + + + + Cancel + handleDeleteBlock(block.id)} + > + Remove Block + + + + +
+
+
+ ))} +
+
+
+ + {/* Mobile Cards */} +
+ {sortedBlocks.map((block) => ( + + +
+
+ + {formatDate(block.date)} + {isBlockPast(block.date) && ( + + Past + + )} +
+
+ {!isBlockPast(block.date) && ( + + )} + + + + + + + Remove Block? + + This will remove the block for{' '} + {formatDate(block.date)} ({block.reason}). + + + + Cancel + handleDeleteBlock(block.id)} + > + Remove Block + + + + +
+
+
+
+ Court: + {block.courtName ? ( + {block.courtName} + ) : ( + All Courts + )} +
+
+ Time: + {block.startTime} - {block.endTime} +
+
+ Reason: + {block.reason} +
+
+ Created by {block.creatorName} +
+
+
+
+ ))} +
+ + )} +
+
+ + {/* Edit Block Dialog */} + !open && setEditingBlock(null)}> + + + Edit Block + Modify the court closure details. + +
+
+ + +
+
+ + setEditDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + /> +
+
+
+ + +
+
+ + +
+
+
+ + setEditReason(e.target.value)} + placeholder='e.g., Tournament, Maintenance' + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/components/admin/AdminCourtManagement.tsx b/components/admin/AdminCourtManagement.tsx index 0de8a2f..81977a4 100644 --- a/components/admin/AdminCourtManagement.tsx +++ b/components/admin/AdminCourtManagement.tsx @@ -19,9 +19,10 @@ import { } from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; 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 { 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 { id: string; @@ -219,7 +220,7 @@ export function AdminCourtManagement() { return ( - Court Management + Courts & Closures
@@ -236,151 +237,176 @@ export function AdminCourtManagement() { } return ( - - - - - Court Management - -
- - - - - - - - {editingCourt ? 'Edit Court' : 'Create New Court'} - -
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder='e.g., Court 1, Main Court' - required - /> -
+
+ + + + + Courts + + + + Closures + + -
- - setFormData({ ...formData, isActive: checked }) - } - /> - -
+ + + + + + Court Management + +
+ + + + + + + + + {editingCourt ? 'Edit Court' : 'Create New Court'} + + + +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder='e.g., Court 1, Main Court' + required + /> +
-
- - -
- -
-
-
-
- - {courts.length === 0 ? ( -
- -

No courts found. Create your first court to get started.

-
- ) : ( -
- {courts.map((court) => ( -
-
-
- -
-

{court.name}

-

- Created {new Date(court.createdAt).toLocaleDateString('en-IE')} -

-
-
+
+ + setFormData({ ...formData, isActive: checked }) + } + /> + +
-
- - {court.isActive ? 'Active' : 'Inactive'} - - -
- - -
-
-
+
+ + +
+ + +
- ))} -
- )} -
+ + + {courts.length === 0 ? ( +
+ +

No courts found. Create your first court to get started.

+
+ ) : ( +
+ {courts.map((court) => ( +
+
+
+ +
+

{court.name}

+

+ Created{' '} + {new Date(court.createdAt).toLocaleDateString('en-IE')} +

+
+
- {/* Delete Confirmation Dialog */} - - - - Are you sure? - - Are you sure you want to delete {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? - This action cannot be undone. - - - - Cancel - - Delete - - - - - +
+ + {court.isActive ? 'Active' : 'Inactive'} + + +
+ + +
+
+
+
+ ))} +
+ )} +
+ + {/* Delete Confirmation Dialog */} + + + + Are you sure? + + Are you sure you want to delete{' '} + {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? This action cannot + be undone. + + + + Cancel + + Delete + + + + +
+ + + + + + + ); } diff --git a/components/admin/admin-dashboard.tsx b/components/admin/admin-dashboard.tsx index 97cd4e4..7beb7b0 100644 --- a/components/admin/admin-dashboard.tsx +++ b/components/admin/admin-dashboard.tsx @@ -274,7 +274,7 @@ export function AdminDashboard() { - {' '} +
diff --git a/components/booking/enhanced-booking-calendar.tsx b/components/booking/enhanced-booking-calendar.tsx index 04bd233..0859698 100644 --- a/components/booking/enhanced-booking-calendar.tsx +++ b/components/booking/enhanced-booking-calendar.tsx @@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; 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'; interface Court { @@ -40,6 +40,8 @@ interface BookingSlot { bookingId?: string; bookedBy?: string; partner?: string; + blocked?: boolean; + blockReason?: string; } interface TimeSlot { @@ -50,6 +52,15 @@ interface TimeSlot { isActive: boolean; } +interface CourtBlock { + id: string; + courtId: string | null; + date: string; + startTime: string; + endTime: string; + reason: string; +} + interface Settings { booking_window_days: string; booking_start_time: string; @@ -63,6 +74,7 @@ export function EnhancedBookingCalendar() { const [bookings, setBookings] = useState([]); const [bookingSlots, setBookingSlots] = useState([]); const [timeSlots, setTimeSlots] = useState([]); + const [blocks, setBlocks] = useState([]); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(false); const [partnerName, setPartnerName] = useState(''); @@ -75,6 +87,7 @@ export function EnhancedBookingCalendar() { fetchSettings(); fetchCourts(); fetchTimeSlots(); + fetchBlocks(); }, []); 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 () => { try { const dateStr = selectedDate.toISOString().split('T')[0]; @@ -236,6 +263,9 @@ export function EnhancedBookingCalendar() { const timeSlots = generateTimeSlots(); const slots: BookingSlot[] = []; + // Get blocks for the selected date + const dateBlocks = blocks.filter((block) => block.date === dateStr); + courts.forEach((court) => { timeSlots.forEach((time) => { const existingBooking = existingBookings.find( @@ -246,6 +276,17 @@ export function EnhancedBookingCalendar() { 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 ? `${existingBooking.user.name} ${existingBooking.user.surname}` : undefined; @@ -256,10 +297,12 @@ export function EnhancedBookingCalendar() { time, courtId: court.id, courtName: court.name, - available: !existingBooking, + available: !existingBooking && !blockingBlock, bookingId: existingBooking?.id, bookedBy, partner, + blocked: !!blockingBlock, + blockReason: blockingBlock?.reason, }); }); }); @@ -302,6 +345,15 @@ export function EnhancedBookingCalendar() { }; 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; // Double-check that this day is actually bookable @@ -570,7 +622,9 @@ export function EnhancedBookingCalendar() {
{slot.courtName}
- {!slot.available && slot.bookedBy && ( -
-
- - Booked by {slot.bookedBy} -
- {slot.partner && ( -
- - Playing with: {slot.partner} + {slot.blocked && ( +
+ + {slot.blockReason || 'Blocked'} +
+ )} + {!slot.blocked && + !slot.available && + slot.bookedBy && ( +
+
+ + Booked by {slot.bookedBy}
- )} -
- )} - {!slot.available && !slot.bookedBy && ( -
- Already booked -
- )} + {slot.partner && ( +
+ + Playing with: {slot.partner} +
+ )} +
+ )} + {!slot.blocked && + !slot.available && + !slot.bookedBy && ( +
+ Already booked +
+ )}
diff --git a/lib/activity-logger.ts b/lib/activity-logger.ts index 02c205c..2dec80b 100644 --- a/lib/activity-logger.ts +++ b/lib/activity-logger.ts @@ -85,6 +85,11 @@ export const ACTIONS = { TIME_SLOT_UPDATE: 'update_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_START: 'system_start', SYSTEM_ERROR: 'system_error', @@ -97,5 +102,6 @@ export const ENTITY_TYPES = { ANNOUNCEMENT: 'announcement', SETTINGS: 'settings', TIME_SLOT: 'time_slot', + COURT_BLOCK: 'court_block', SYSTEM: 'system', } as const; diff --git a/lib/db/migrations/0002_thick_makkari.sql b/lib/db/migrations/0002_thick_makkari.sql new file mode 100644 index 0000000..8b22eed --- /dev/null +++ b/lib/db/migrations/0002_thick_makkari.sql @@ -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; \ No newline at end of file diff --git a/lib/db/migrations/meta/0002_snapshot.json b/lib/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..fa33fd6 --- /dev/null +++ b/lib/db/migrations/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 295ca29..31a64fb 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1758824962110, "tag": "0001_slimy_starjammers", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1766916904651, + "tag": "0002_thick_makkari", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 75e648d..be57b0f 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -104,6 +104,20 @@ export const metrics = sqliteTable('metrics', { 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 export const insertUserSchema = createInsertSchema(users); export const selectUserSchema = createSelectSchema(users); @@ -121,6 +135,8 @@ export const insertActivityLogSchema = createInsertSchema(activityLogs); export const selectActivityLogSchema = createSelectSchema(activityLogs); export const insertMetricSchema = createInsertSchema(metrics); export const selectMetricSchema = createSelectSchema(metrics); +export const insertCourtBlockSchema = createInsertSchema(courtBlocks); +export const selectCourtBlockSchema = createSelectSchema(courtBlocks); // Types export type User = typeof users.$inferSelect; @@ -139,3 +155,5 @@ export type ActivityLog = typeof activityLogs.$inferSelect; export type NewActivityLog = typeof activityLogs.$inferInsert; export type Metric = typeof metrics.$inferSelect; export type NewMetric = typeof metrics.$inferInsert; +export type CourtBlock = typeof courtBlocks.$inferSelect; +export type NewCourtBlock = typeof courtBlocks.$inferInsert; -- 2.52.0