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
|
||||
|
||||
Reference in New Issue
Block a user