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:
mikicvi
2025-12-29 17:04:16 +00:00
parent 54240a2cfd
commit 40c56770a2
13 changed files with 2164 additions and 215 deletions
+128
View File
@@ -0,0 +1,128 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
const { courtId, date, startTime, endTime, reason } = await request.json();
if (!date || !startTime || !endTime || !reason) {
return NextResponse.json(
{ error: 'Missing required fields: date, startTime, endTime, reason' },
{ status: 400 }
);
}
// Check if block exists
const existingBlock = await db.select().from(courtBlocks).where(eq(courtBlocks.id, id)).limit(1);
if (existingBlock.length === 0) {
return NextResponse.json({ error: 'Block not found' }, { status: 404 });
}
// Validate date is not in the past
const blockDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (blockDate < today) {
return NextResponse.json({ error: 'Cannot set blocks for past dates' }, { status: 400 });
}
// If courtId is provided, verify it exists
if (courtId) {
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
if (court.length === 0) {
return NextResponse.json({ error: 'Court not found' }, { status: 400 });
}
}
// Update the block
const [updatedBlock] = await db
.update(courtBlocks)
.set({
courtId: courtId || null,
date,
startTime,
endTime,
reason,
})
.where(eq(courtBlocks.id, id))
.returning();
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_UPDATE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: id,
details: {
courtId: courtId || 'all',
date,
startTime,
endTime,
reason,
},
});
return NextResponse.json({
block: updatedBlock,
message: 'Block updated successfully',
});
} catch (error) {
console.error('Error updating block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
// Check if block exists
const existingBlock = await db.select().from(courtBlocks).where(eq(courtBlocks.id, id)).limit(1);
if (existingBlock.length === 0) {
return NextResponse.json({ error: 'Block not found' }, { status: 404 });
}
const block = existingBlock[0];
// Delete the block
await db.delete(courtBlocks).where(eq(courtBlocks.id, id));
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_DELETE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: id,
details: {
courtId: block.courtId || 'all',
date: block.date,
startTime: block.startTime,
endTime: block.endTime,
reason: block.reason,
},
});
return NextResponse.json({ message: 'Block deleted successfully' });
} catch (error) {
console.error('Error deleting block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+176
View File
@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts, users, announcements } from '@/lib/db/schema';
import { eq, gte, asc } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const includeExpired = searchParams.get('includeExpired') === 'true';
// Get today's date
const today = new Date().toISOString().split('T')[0];
// Fetch blocks with court and creator info
let query = db
.select({
id: courtBlocks.id,
courtId: courtBlocks.courtId,
date: courtBlocks.date,
startTime: courtBlocks.startTime,
endTime: courtBlocks.endTime,
reason: courtBlocks.reason,
createdBy: courtBlocks.createdBy,
createdAt: courtBlocks.createdAt,
court: {
id: courts.id,
name: courts.name,
},
creator: {
id: users.id,
name: users.name,
surname: users.surname,
},
})
.from(courtBlocks)
.leftJoin(courts, eq(courtBlocks.courtId, courts.id))
.innerJoin(users, eq(courtBlocks.createdBy, users.id));
const rawBlocks = includeExpired
? await query.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime))
: await query
.where(gte(courtBlocks.date, today))
.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime));
// Transform to flat structure for frontend
const blocks = rawBlocks.map((block) => ({
id: block.id,
courtId: block.courtId,
courtName: block.court?.name || null,
date: block.date,
startTime: block.startTime,
endTime: block.endTime,
reason: block.reason,
createdBy: block.createdBy,
creatorName: block.creator ? `${block.creator.name} ${block.creator.surname}` : 'Unknown',
createdAt: block.createdAt,
}));
return NextResponse.json({ blocks });
} catch (error) {
console.error('Error fetching blocks:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { courtId, date, startTime, endTime, reason, createAnnouncement } = await request.json();
if (!date || !startTime || !endTime || !reason) {
return NextResponse.json(
{ error: 'Missing required fields: date, startTime, endTime, reason' },
{ status: 400 }
);
}
// Validate date is not in the past
const blockDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (blockDate < today) {
return NextResponse.json({ error: 'Cannot create blocks for past dates' }, { status: 400 });
}
// Get court name if courtId is provided
let courtName = 'All Courts';
if (courtId) {
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
if (court.length === 0) {
return NextResponse.json({ error: 'Court not found' }, { status: 400 });
}
courtName = court[0].name;
}
// Create the block
const blockId = crypto.randomUUID();
const [newBlock] = await db
.insert(courtBlocks)
.values({
id: blockId,
courtId: courtId || null, // null means all courts
date,
startTime,
endTime,
reason,
createdBy: session.userId,
createdAt: new Date(),
})
.returning();
// Log activity
await logActivity({
userId: session.userId,
action: ACTIONS.BLOCK_CREATE,
entityType: ENTITY_TYPES.COURT_BLOCK,
entityId: blockId,
details: {
courtId: courtId || 'all',
date,
startTime,
endTime,
reason,
},
});
// Optionally create an announcement that expires when the block ends
let announcementCreated = false;
if (createAnnouncement) {
const formattedDate = new Date(date).toLocaleDateString('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
// Set expiry to end of block day at the end time
const expiryDate = new Date(date);
const [endHour] = endTime.split(':').map(Number);
expiryDate.setHours(endHour, 0, 0, 0);
await db.insert(announcements).values({
id: crypto.randomUUID(),
title: `${reason} - ${formattedDate}`,
content: `${courtName} will be unavailable on ${formattedDate} from ${startTime} to ${endTime} due to: ${reason}`,
priority: 'high',
expiresAt: expiryDate,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
announcementCreated = true;
}
return NextResponse.json({
block: newBlock,
announcementCreated,
message: 'Block created successfully',
});
} catch (error) {
console.error('Error creating block:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { courtBlocks, courts } from '@/lib/db/schema';
import { eq, gte, and, lte, asc } from 'drizzle-orm';
import { getSession } from '@/lib/session';
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
// Default to today if no start date provided
const today = new Date().toISOString().split('T')[0];
const queryStartDate = startDate || today;
// Default to 60 days ahead if no end date provided (8 weeks + buffer)
const defaultEndDate = new Date();
defaultEndDate.setDate(defaultEndDate.getDate() + 60);
const queryEndDate = endDate || defaultEndDate.toISOString().split('T')[0];
// Fetch blocks with court info for the date range
const blocks = await db
.select({
id: courtBlocks.id,
courtId: courtBlocks.courtId,
date: courtBlocks.date,
startTime: courtBlocks.startTime,
endTime: courtBlocks.endTime,
reason: courtBlocks.reason,
court: {
id: courts.id,
name: courts.name,
},
})
.from(courtBlocks)
.leftJoin(courts, eq(courtBlocks.courtId, courts.id))
.where(and(gte(courtBlocks.date, queryStartDate), lte(courtBlocks.date, queryEndDate)))
.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime));
return NextResponse.json({ blocks });
} catch (error) {
console.error('Error fetching blocks:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+92 -45
View File
@@ -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