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.
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+92
-45
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Court Management</CardTitle>
|
||||
<CardTitle>Courts & Closures</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
@@ -236,151 +237,176 @@ export function AdminCourtManagement() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Settings className='h-5 w-5' />
|
||||
Court Management
|
||||
</CardTitle>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' variant='outline' onClick={fetchCourts}>
|
||||
<RefreshCw className='h-4 w-4 mr-2' />
|
||||
Refresh
|
||||
</Button>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size='sm' onClick={() => setEditingCourt(null)}>
|
||||
<Plus className='h-4 w-4 mr-2' />
|
||||
Add Court
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingCourt ? 'Edit Court' : 'Create New Court'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='name'>Court Name</Label>
|
||||
<Input
|
||||
id='name'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder='e.g., Court 1, Main Court'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='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>
|
||||
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Switch
|
||||
id='isActive'
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormData({ ...formData, isActive: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor='isActive'>Active (available for booking)</Label>
|
||||
</div>
|
||||
<TabsContent value='courts'>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Settings className='h-5 w-5' />
|
||||
Court Management
|
||||
</CardTitle>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' variant='outline' onClick={fetchCourts}>
|
||||
<RefreshCw className='h-4 w-4 mr-2' />
|
||||
Refresh
|
||||
</Button>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size='sm' onClick={() => setEditingCourt(null)}>
|
||||
<Plus className='h-4 w-4 mr-2' />
|
||||
Add Court
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCourt ? 'Edit Court' : 'Create New Court'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='name'>Court Name</Label>
|
||||
<Input
|
||||
id='name'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder='e.g., Court 1, Main Court'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={creating || Boolean(editing)}>
|
||||
{creating || editing ? (
|
||||
<>
|
||||
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
||||
{editingCourt ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : editingCourt ? (
|
||||
'Update Court'
|
||||
) : (
|
||||
'Create Court'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{courts.length === 0 ? (
|
||||
<div className='text-center py-8 text-gray-500'>
|
||||
<MapPin className='h-12 w-12 mx-auto mb-4 text-gray-300' />
|
||||
<p>No courts found. Create your first court to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{courts.map((court) => (
|
||||
<div key={court.id} className='border rounded-lg p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<MapPin className='h-5 w-5 text-blue-600' />
|
||||
<div>
|
||||
<h3 className='font-medium'>{court.name}</h3>
|
||||
<p className='text-sm text-gray-500'>
|
||||
Created {new Date(court.createdAt).toLocaleDateString('en-IE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Switch
|
||||
id='isActive'
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormData({ ...formData, isActive: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor='isActive'>Active (available for booking)</Label>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<Badge variant={court.isActive ? 'default' : 'secondary'}>
|
||||
{court.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleEdit(court)}
|
||||
disabled={editing === court.id}
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => openDeleteDialog(court)}
|
||||
disabled={deleting === court.id}
|
||||
className='text-red-600 hover:text-red-700'
|
||||
>
|
||||
{deleting === court.id ? (
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<Button type='button' variant='outline' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={creating || Boolean(editing)}>
|
||||
{creating || editing ? (
|
||||
<>
|
||||
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
||||
{editingCourt ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : editingCourt ? (
|
||||
'Update Court'
|
||||
) : (
|
||||
'Create Court'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{courts.length === 0 ? (
|
||||
<div className='text-center py-8 text-gray-500'>
|
||||
<MapPin className='h-12 w-12 mx-auto mb-4 text-gray-300' />
|
||||
<p>No courts found. Create your first court to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{courts.map((court) => (
|
||||
<div key={court.id} className='border rounded-lg p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<MapPin className='h-5 w-5 text-blue-600' />
|
||||
<div>
|
||||
<h3 className='font-medium'>{court.name}</h3>
|
||||
<p className='text-sm text-gray-500'>
|
||||
Created{' '}
|
||||
{new Date(court.createdAt).toLocaleDateString('en-IE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteCourt}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Badge variant={court.isActive ? 'default' : 'secondary'}>
|
||||
{court.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => handleEdit(court)}
|
||||
disabled={editing === court.id}
|
||||
>
|
||||
<Edit className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => openDeleteDialog(court)}
|
||||
disabled={deleting === court.id}
|
||||
className='text-red-600 hover:text-red-700'
|
||||
>
|
||||
{deleting === court.id ? (
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{' '}
|
||||
{courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? This action cannot
|
||||
be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteCourt}
|
||||
className='bg-destructive hover:bg-destructive/90'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='closures'>
|
||||
<AdminBlocksManagement />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ export function AdminDashboard() {
|
||||
</TabsContent>
|
||||
<TabsContent value='courts'>
|
||||
<AdminCourtManagement />
|
||||
</TabsContent>{' '}
|
||||
</TabsContent>
|
||||
<TabsContent value='settings'>
|
||||
<div className='space-y-6'>
|
||||
<AdminSettingsManagement />
|
||||
|
||||
@@ -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<Booking[]>([]);
|
||||
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
|
||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||
const [blocks, setBlocks] = useState<CourtBlock[]>([]);
|
||||
const [settings, setSettings] = useState<Settings | null>(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() {
|
||||
<div
|
||||
key={`${slot.courtId}-${slot.time}`}
|
||||
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-muted bg-muted/50 cursor-not-allowed opacity-75 hover:opacity-100'
|
||||
}`}
|
||||
@@ -582,39 +636,59 @@ export function EnhancedBookingCalendar() {
|
||||
<MapPin className='h-4 w-4' />
|
||||
{slot.courtName}
|
||||
</div>
|
||||
{!slot.available && slot.bookedBy && (
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
<Users className='h-3 w-3' />
|
||||
Booked by {slot.bookedBy}
|
||||
</div>
|
||||
{slot.partner && (
|
||||
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
|
||||
<User className='h-3 w-3' />
|
||||
Playing with: {slot.partner}
|
||||
{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='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
<Users className='h-3 w-3' />
|
||||
Booked by {slot.bookedBy}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!slot.available && !slot.bookedBy && (
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Already booked
|
||||
</div>
|
||||
)}
|
||||
{slot.partner && (
|
||||
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
|
||||
<User className='h-3 w-3' />
|
||||
Playing with: {slot.partner}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!slot.blocked &&
|
||||
!slot.available &&
|
||||
!slot.bookedBy && (
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Already booked
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
disabled={!slot.available}
|
||||
disabled={!slot.available || slot.blocked}
|
||||
variant={
|
||||
slot.available ? 'default' : 'secondary'
|
||||
slot.blocked
|
||||
? 'outline'
|
||||
: slot.available
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
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'
|
||||
: 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
>
|
||||
{slot.available ? 'Book' : 'Booked'}
|
||||
{slot.blocked
|
||||
? 'Blocked'
|
||||
: slot.available
|
||||
? 'Book'
|
||||
: 'Booked'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1758824962110,
|
||||
"tag": "0001_slimy_starjammers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1766916904651,
|
||||
"tag": "0002_thick_makkari",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user