additional features, refinement and more control over the app from admin side, better bookings UX

This commit is contained in:
mikicvi
2025-09-25 20:23:18 +01:00
parent 6d3202e385
commit b89d91ade2
20 changed files with 1358 additions and 328 deletions
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { db } from '@/lib/db';
import { users, courts, bookings, metrics } from '@/lib/db/schema';
import { eq, and, gte, lte, desc, count } from 'drizzle-orm';
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get real stats from database
const totalUsers = await db.select({ count: count() }).from(users);
const activeCourts = await db.select({ count: count() }).from(courts).where(eq(courts.isActive, true));
// Get today's bookings count
const today = new Date().toISOString().split('T')[0];
const todaysBookings = await db
.select({ count: count() })
.from(bookings)
.where(and(eq(bookings.date, today), eq(bookings.status, 'active')));
// Get current month's bookings from metrics table
const currentMonth = new Date().toISOString().substring(0, 7); // "2025-09"
const monthlyBookings = await db
.select()
.from(metrics)
.where(and(eq(metrics.metricType, 'monthly_bookings'), eq(metrics.period, currentMonth)))
.limit(1);
// Get recent bookings with user names
const recentBookings = await db
.select({
id: bookings.id,
date: bookings.date,
startTime: bookings.startTime,
endTime: bookings.endTime,
courtName: courts.name,
userName: users.name,
userSurname: users.surname,
status: bookings.status,
createdAt: bookings.createdAt,
})
.from(bookings)
.leftJoin(users, eq(bookings.userId, users.id))
.leftJoin(courts, eq(bookings.courtId, courts.id))
.orderBy(desc(bookings.createdAt))
.limit(10);
return NextResponse.json({
stats: {
totalUsers: totalUsers[0]?.count || 0,
activeCourts: activeCourts[0]?.count || 0,
todaysBookings: todaysBookings[0]?.count || 0,
monthlyBookings: monthlyBookings[0]?.value || 0,
},
recentBookings: recentBookings.map((booking) => ({
...booking,
userName: `${booking.userName} ${booking.userSurname}`,
})),
});
} catch (error) {
console.error('Error fetching admin stats:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+2 -8
View File
@@ -31,17 +31,11 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (startTime && !timeRegex.test(startTime)) {
return NextResponse.json(
{ error: 'Invalid startTime format. Use HH:MM format' },
{ status: 400 }
);
return NextResponse.json({ error: 'Invalid startTime format. Use HH:MM format' }, { status: 400 });
}
if (endTime && !timeRegex.test(endTime)) {
return NextResponse.json(
{ error: 'Invalid endTime format. Use HH:MM format' },
{ status: 400 }
);
return NextResponse.json({ error: 'Invalid endTime format. Use HH:MM format' }, { status: 400 });
}
const updatedTimeSlot = await db
+2 -8
View File
@@ -12,10 +12,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const allTimeSlots = await db
.select()
.from(timeSlots)
.orderBy(timeSlots.dayOfWeek, timeSlots.startTime);
const allTimeSlots = await db.select().from(timeSlots).orderBy(timeSlots.dayOfWeek, timeSlots.startTime);
return NextResponse.json({
timeSlots: allTimeSlots,
@@ -53,10 +50,7 @@ export async function POST(request: NextRequest) {
// Validate time format (HH:MM)
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(startTime) || !timeRegex.test(endTime)) {
return NextResponse.json(
{ error: 'Invalid time format. Use HH:MM format' },
{ status: 400 }
);
return NextResponse.json({ error: 'Invalid time format. Use HH:MM format' }, { status: 400 });
}
const newTimeSlot = await db
+77 -12
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { bookings, courts, timeSlots } from '@/lib/db/schema';
import { bookings, courts, timeSlots, settings, metrics } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
@@ -45,7 +45,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { courtId, date, timeSlot } = await request.json();
const { courtId, date, timeSlot, notes } = await request.json();
if (!courtId || !date || !timeSlot) {
return NextResponse.json(
@@ -91,18 +91,15 @@ export async function POST(request: NextRequest) {
const availableTimeSlots = await db
.select()
.from(timeSlots)
.where(
and(
eq(timeSlots.dayOfWeek, dayOfWeek),
eq(timeSlots.isActive, true)
)
);
.where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
// Check if any time slots are configured for this day
if (availableTimeSlots.length === 0) {
return NextResponse.json(
{
error: `No bookings are allowed on ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]}s. The facility is closed on this day.`,
error: `No bookings are allowed on ${
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
}s. The facility is closed on this day.`,
},
{ status: 400 }
);
@@ -110,17 +107,50 @@ 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 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(', ');
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}`,
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);
const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1; // Default to 1 if setting not found
// 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 }
);
@@ -160,6 +190,7 @@ export async function POST(request: NextRequest) {
startTime,
endTime,
status: 'active',
notes: notes || null, // Include notes from the request
createdAt: new Date(),
updatedAt: new Date(),
})
@@ -181,6 +212,40 @@ export async function POST(request: NextRequest) {
request,
});
// Update monthly metrics
const currentMonth = new Date().toISOString().substring(0, 7); // "2025-09"
try {
const existingMetric = await db
.select()
.from(metrics)
.where(and(eq(metrics.metricType, 'monthly_bookings'), eq(metrics.period, currentMonth)))
.limit(1);
if (existingMetric.length > 0) {
// Increment existing metric
await db
.update(metrics)
.set({
value: existingMetric[0].value + 1,
updatedAt: new Date(),
})
.where(eq(metrics.id, existingMetric[0].id));
} else {
// Create new metric for this month
await db.insert(metrics).values({
id: crypto.randomUUID(),
metricType: 'monthly_bookings',
period: currentMonth,
value: 1,
createdAt: new Date(),
updatedAt: new Date(),
});
}
} catch (error) {
console.error('Error updating monthly metrics:', error);
// Don't fail the booking if metrics update fails
}
return NextResponse.json({
booking: newBooking,
message: 'Booking created successfully',
+30 -2
View File
@@ -1,5 +1,8 @@
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { EnhancedBookingCalendar } from '@/components/booking/enhanced-booking-calendar';
import { UserBookingManagement } from '@/components/booking/user-booking-management';
@@ -11,9 +14,32 @@ export default async function DashboardPage() {
redirect('/login');
}
// Get full user information
const [user] = await db
.select({
id: users.id,
email: users.email,
name: users.name,
surname: users.surname,
role: users.role,
})
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
if (!user) {
redirect('/login');
}
const userWithSession = {
...session,
name: user.name,
surname: user.surname,
};
return (
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100'>
<DashboardHeader user={session} />
<DashboardHeader user={userWithSession} />
<main className='container mx-auto px-4 py-8'>
<div className='grid gap-8 lg:grid-cols-3'>
@@ -21,7 +47,9 @@ export default async function DashboardPage() {
<div className='lg:col-span-2 space-y-6'>
<div>
<h1 className='text-3xl font-bold text-gray-900 mb-2'>
Welcome back, {session.email.split('@')[0]}! 🏓
Welcome back,{' '}
{user.name && user.surname ? `${user.name} ${user.surname}` : user.email.split('@')[0]}!
🏓
</h1>
<p className='text-gray-600'>Book your table tennis court and enjoy your game</p>
</div>
@@ -23,6 +23,7 @@ interface SettingsData {
booking_start_time: string;
booking_end_time: string;
allow_weekend_bookings: string;
max_bookings_per_user_per_hour_per_day: string;
}
export function AdminSettingsManagement() {
@@ -33,6 +34,7 @@ export function AdminSettingsManagement() {
booking_start_time: '08:00',
booking_end_time: '22:00',
allow_weekend_bookings: 'true',
max_bookings_per_user_per_hour_per_day: '1',
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -54,6 +56,7 @@ export function AdminSettingsManagement() {
booking_start_time: '08:00',
booking_end_time: '22:00',
allow_weekend_bookings: 'true',
max_bookings_per_user_per_hour_per_day: '1',
};
// Map the settings array to our object
@@ -249,6 +252,20 @@ export function AdminSettingsManagement() {
<p className='text-sm text-gray-500'>When courts close for booking each day</p>
</div>
{/* Booking Restrictions */}
<div className='space-y-2'>
<Label htmlFor='max_bookings_per_user_per_hour_per_day'>Max Bookings per User per Hour</Label>
<Input
id='max_bookings_per_user_per_hour_per_day'
type='number'
min='1'
max='5'
value={settings.max_bookings_per_user_per_hour_per_day}
onChange={(e) => updateSetting('max_bookings_per_user_per_hour_per_day', e.target.value)}
/>
<p className='text-sm text-gray-500'>Maximum bookings per user per hour on the same day</p>
</div>
{/* Weekend Bookings */}
<div className='space-y-2'>
<div className='flex items-center space-x-2'>
@@ -286,6 +303,10 @@ export function AdminSettingsManagement() {
<strong>Weekend Bookings:</strong>{' '}
{settings.allow_weekend_bookings === 'true' ? 'Enabled' : 'Disabled'}
</p>
<p>
<strong>Booking Limit:</strong> {settings.max_bookings_per_user_per_hour_per_day} per
hour
</p>
</div>
</div>
</div>
+111 -72
View File
@@ -21,15 +21,7 @@ interface TimeSlot {
updatedAt: string;
}
const DAYS = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
];
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
export function AdminTimeSlotManagement() {
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
@@ -80,9 +72,7 @@ export function AdminTimeSlotManagement() {
try {
setLoading(true);
const url = editingSlot
? `/api/admin/time-slots/${editingSlot.id}`
: '/api/admin/time-slots';
const url = editingSlot ? `/api/admin/time-slots/${editingSlot.id}` : '/api/admin/time-slots';
const method = editingSlot ? 'PUT' : 'POST';
@@ -97,9 +87,7 @@ export function AdminTimeSlotManagement() {
if (response.ok) {
toast({
title: 'Success',
description: editingSlot
? 'Time slot updated successfully'
: 'Time slot created successfully',
description: editingSlot ? 'Time slot updated successfully' : 'Time slot created successfully',
});
fetchTimeSlots();
setShowDialog(false);
@@ -161,6 +149,51 @@ export function AdminTimeSlotManagement() {
}
};
const handleWipeDay = async (dayOfWeek: number) => {
const dayName = DAYS[dayOfWeek];
if (!confirm(`Are you sure you want to delete ALL time slots for ${dayName}? This action cannot be undone.`)) {
return;
}
try {
setLoading(true);
const slotsToDelete = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek);
// Delete all slots for this day
const deletePromises = slotsToDelete.map((slot) =>
fetch(`/api/admin/time-slots/${slot.id}`, {
method: 'DELETE',
})
);
const responses = await Promise.all(deletePromises);
const successCount = responses.filter((response) => response.ok).length;
if (successCount === slotsToDelete.length) {
toast({
title: 'Success',
description: `All ${dayName} time slots deleted successfully`,
});
fetchTimeSlots();
} else {
toast({
title: 'Partial Success',
description: `${successCount} of ${slotsToDelete.length} slots deleted`,
variant: 'destructive',
});
}
} catch (error) {
console.error('Error wiping day slots:', error);
toast({
title: 'Error',
description: 'Failed to delete day slots',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleEdit = (slot: TimeSlot) => {
setEditingSlot(slot);
setFormData({
@@ -193,27 +226,25 @@ export function AdminTimeSlotManagement() {
return (
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
<div className='flex justify-between items-center'>
<CardTitle className='flex items-center gap-2'>
<Clock className='h-5 w-5' />
Time Slot Management
</CardTitle>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button onClick={resetForm}>
<Plus className="h-4 w-4 mr-2" />
<Plus className='h-4 w-4 mr-2' />
Add Time Slot
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingSlot ? 'Edit Time Slot' : 'Add New Time Slot'}
</DialogTitle>
<DialogTitle>{editingSlot ? 'Edit Time Slot' : 'Add New Time Slot'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className='space-y-4'>
<div>
<Label htmlFor="dayOfWeek">Day of Week</Label>
<Label htmlFor='dayOfWeek'>Day of Week</Label>
<Select
value={formData.dayOfWeek.toString()}
onValueChange={(value) =>
@@ -221,7 +252,7 @@ export function AdminTimeSlotManagement() {
}
>
<SelectTrigger>
<SelectValue placeholder="Select day" />
<SelectValue placeholder='Select day' />
</SelectTrigger>
<SelectContent>
{DAYS.map((day, index) => (
@@ -233,48 +264,38 @@ export function AdminTimeSlotManagement() {
</Select>
</div>
<div>
<Label htmlFor="startTime">Start Time</Label>
<Label htmlFor='startTime'>Start Time</Label>
<Input
id="startTime"
type="time"
id='startTime'
type='time'
value={formData.startTime}
onChange={(e) =>
setFormData({ ...formData, startTime: e.target.value })
}
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="endTime">End Time</Label>
<Label htmlFor='endTime'>End Time</Label>
<Input
id="endTime"
type="time"
id='endTime'
type='time'
value={formData.endTime}
onChange={(e) =>
setFormData({ ...formData, endTime: e.target.value })
}
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
required
/>
</div>
<div className="flex items-center space-x-2">
<div className='flex items-center space-x-2'>
<Switch
id="isActive"
id='isActive'
checked={formData.isActive}
onCheckedChange={(checked) =>
setFormData({ ...formData, isActive: checked })
}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive">Active</Label>
<Label htmlFor='isActive'>Active</Label>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setShowDialog(false)}
>
<div className='flex justify-end space-x-2'>
<Button type='button' variant='outline' onClick={() => setShowDialog(false)}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
<Button type='submit' disabled={loading}>
{loading ? 'Saving...' : editingSlot ? 'Update' : 'Create'}
</Button>
</div>
@@ -285,57 +306,75 @@ export function AdminTimeSlotManagement() {
</CardHeader>
<CardContent>
{loading && timeSlots.length === 0 ? (
<div className="text-center py-4">Loading time slots...</div>
<div className='text-center py-4'>Loading time slots...</div>
) : (
<div className="space-y-6">
<div className='space-y-6'>
{DAYS.map((day, dayIndex) => (
<div key={dayIndex} className="space-y-2">
<h3 className="font-semibold text-lg">{day}</h3>
<div key={dayIndex} className='space-y-2'>
<div className='flex justify-between items-center'>
<h3 className='font-semibold text-lg'>{day}</h3>
{groupedTimeSlots[dayIndex]?.length > 0 && (
<Button
size='sm'
variant='outline'
onClick={() => handleWipeDay(dayIndex)}
className='text-red-600 hover:text-red-700 hover:bg-red-50'
disabled={loading}
>
<Trash2 className='h-4 w-4 mr-1' />
Wipe All
</Button>
)}
</div>
{groupedTimeSlots[dayIndex]?.length > 0 ? (
<div className="grid gap-2">
<div className='grid gap-2'>
{groupedTimeSlots[dayIndex]
.sort((a, b) => a.startTime.localeCompare(b.startTime))
.map((slot) => (
<div
key={slot.id}
className={`flex items-center justify-between p-3 border rounded-lg ${
slot.isActive ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200'
slot.isActive
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex items-center space-x-3">
<div className="font-medium">
<div className='flex items-center space-x-3'>
<div className='font-medium'>
{slot.startTime} - {slot.endTime}
</div>
<div className={`px-2 py-1 rounded-full text-xs ${
slot.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
<div
className={`px-2 py-1 rounded-full text-xs ${
slot.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{slot.isActive ? 'Active' : 'Inactive'}
</div>
</div>
<div className="flex space-x-2">
<div className='flex space-x-2'>
<Button
size="sm"
variant="outline"
size='sm'
variant='outline'
onClick={() => handleEdit(slot)}
>
<Edit className="h-4 w-4" />
<Edit className='h-4 w-4' />
</Button>
<Button
size="sm"
variant="outline"
size='sm'
variant='outline'
onClick={() => handleDelete(slot.id)}
className="text-red-600 hover:text-red-700"
className='text-red-600 hover:text-red-700'
>
<Trash2 className="h-4 w-4" />
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500 italic">No time slots configured for {day}</p>
<p className='text-gray-500 italic'>No time slots configured for {day}</p>
)}
</div>
))}
+25 -6
View File
@@ -71,8 +71,12 @@ export function AdminUserManagement() {
}
};
const handleCreateUser = async () => {
const handleCreateUser = async (e?: React.FormEvent) => {
try {
// Prevent form submission and double submissions
if (e) e.preventDefault();
if (loading) return;
if (!formData.name || !formData.surname || !formData.email || !formData.password) {
toast({
title: 'Error',
@@ -82,6 +86,8 @@ export function AdminUserManagement() {
return;
}
setLoading(true);
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: {
@@ -114,11 +120,16 @@ export function AdminUserManagement() {
description: 'Failed to create user',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleEditUser = async () => {
try {
// Prevent double submissions
if (loading) return;
if (!editingUser || !formData.name || !formData.surname || !formData.email) {
toast({
title: 'Error',
@@ -128,6 +139,8 @@ export function AdminUserManagement() {
return;
}
setLoading(true);
const updateData = { ...formData };
if (!updateData.password) {
delete updateData.password; // Don't update password if not provided
@@ -166,6 +179,8 @@ export function AdminUserManagement() {
description: 'Failed to update user',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
@@ -262,7 +277,7 @@ export function AdminUserManagement() {
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
</DialogHeader>
<div className='space-y-4'>
<form onSubmit={handleCreateUser} className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div>
<Label htmlFor='name'>First Name</Label>
@@ -321,12 +336,14 @@ export function AdminUserManagement() {
</Select>
</div>
<div className='flex gap-2 justify-end'>
<Button variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
<Button type='button' variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateUser}>Create User</Button>
<Button type='submit' disabled={loading}>
{loading ? 'Creating...' : 'Create User'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
@@ -474,7 +491,9 @@ export function AdminUserManagement() {
<Button variant='outline' onClick={() => setIsEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleEditUser}>Update User</Button>
<Button onClick={handleEditUser} disabled={loading}>
{loading ? 'Updating...' : 'Update User'}
</Button>
</div>
</div>
</DialogContent>
+63 -25
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -15,14 +15,52 @@ import { AdminCourtManagement } from './AdminCourtManagement';
import { AdminSettingsManagement } from './AdminSettingsManagement';
import { AdminTimeSlotManagement } from './AdminTimeSlotManagement';
interface AdminStats {
totalUsers: number;
activeCourts: number;
todaysBookings: number;
monthlyBookings: number;
}
interface RecentBooking {
id: string;
date: string;
startTime: string;
endTime: string;
courtName: string;
userName: string;
status: string;
}
export function AdminDashboard() {
const router = useRouter();
const [stats] = useState({
totalUsers: 125,
todayBookings: 18,
totalCourts: 2,
weeklyRevenue: 850,
const [stats, setStats] = useState<AdminStats>({
totalUsers: 0,
activeCourts: 0,
todaysBookings: 0,
monthlyBookings: 0,
});
const [recentBookings, setRecentBookings] = useState<RecentBooking[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchStats();
}, []);
const fetchStats = async () => {
try {
const response = await fetch('/api/admin/stats');
if (response.ok) {
const data = await response.json();
setStats(data.stats);
setRecentBookings(data.recentBookings);
}
} catch (error) {
console.error('Error fetching admin stats:', error);
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
try {
@@ -68,19 +106,8 @@ export function AdminDashboard() {
<Users className='h-4 w-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{stats.totalUsers}</div>
<p className='text-xs text-muted-foreground'>+12% from last month</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Today's Bookings</CardTitle>
<Calendar className='h-4 w-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{stats.todayBookings}</div>
<p className='text-xs text-muted-foreground'>+5% from yesterday</p>
<div className='text-2xl font-bold'>{loading ? '...' : stats.totalUsers}</div>
<p className='text-xs text-muted-foreground'>Registered users</p>
</CardContent>
</Card>
@@ -90,19 +117,30 @@ export function AdminDashboard() {
<MapPin className='h-4 w-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{stats.totalCourts}</div>
<p className='text-xs text-muted-foreground'>All courts operational</p>
<div className='text-2xl font-bold'>{loading ? '...' : stats.activeCourts}</div>
<p className='text-xs text-muted-foreground'>Available for booking</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Weekly Revenue</CardTitle>
<CardTitle className='text-sm font-medium'>Today's Bookings</CardTitle>
<Calendar className='h-4 w-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{loading ? '...' : stats.todaysBookings}</div>
<p className='text-xs text-muted-foreground'>Bookings for today</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Monthly Bookings</CardTitle>
<BarChart3 className='h-4 w-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>${stats.weeklyRevenue}</div>
<p className='text-xs text-muted-foreground'>+8% from last week</p>
<div className='text-2xl font-bold'>{loading ? '...' : stats.monthlyBookings}</div>
<p className='text-xs text-muted-foreground'>This month's total</p>
</CardContent>
</Card>
</div>
@@ -127,7 +165,7 @@ export function AdminDashboard() {
<AdminCourtManagement />
</TabsContent>{' '}
<TabsContent value='settings'>
<div className="space-y-6">
<div className='space-y-6'>
<AdminSettingsManagement />
<AdminTimeSlotManagement />
</div>
+152 -77
View File
@@ -39,6 +39,7 @@ interface BookingSlot {
available: boolean;
bookingId?: string;
bookedBy?: string;
partner?: string;
}
interface TimeSlot {
@@ -178,14 +179,12 @@ export function EnhancedBookingCalendar() {
const dayOfWeek = selectedDate.getDay();
// Get time slots for the selected day
const dayTimeSlots = timeSlots.filter(
slot => slot.dayOfWeek === dayOfWeek && slot.isActive
);
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
if (dayTimeSlots.length > 0) {
// Use day-specific time slots
const slots: string[] = [];
dayTimeSlots.forEach(timeSlot => {
dayTimeSlots.forEach((timeSlot) => {
const start = parseInt(timeSlot.startTime.split(':')[0]);
const end = parseInt(timeSlot.endTime.split(':')[0]);
@@ -205,9 +204,7 @@ export function EnhancedBookingCalendar() {
const isDayBookable = (): boolean => {
const dayOfWeek = selectedDate.getDay();
const dayTimeSlots = timeSlots.filter(
slot => slot.dayOfWeek === dayOfWeek && slot.isActive
);
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
return dayTimeSlots.length > 0;
};
@@ -216,6 +213,24 @@ export function EnhancedBookingCalendar() {
return days[dayOfWeek];
};
const parseBookingNotes = (notes?: string) => {
if (!notes) return { partner: '', additionalNotes: '' };
const parts = notes.split(' | ');
let partner = '';
let additionalNotes = '';
parts.forEach((part) => {
if (part.startsWith('Partner: ')) {
partner = part.replace('Partner: ', '');
} else {
additionalNotes = additionalNotes ? `${additionalNotes} | ${part}` : part;
}
});
return { partner, additionalNotes };
};
const generateBookingSlots = (existingBookings: Booking[]) => {
const dateStr = selectedDate.toISOString().split('T')[0];
const timeSlots = generateTimeSlots();
@@ -235,6 +250,8 @@ export function EnhancedBookingCalendar() {
? `${existingBooking.user.name} ${existingBooking.user.surname}`
: undefined;
const { partner } = parseBookingNotes(existingBooking?.notes);
slots.push({
time,
courtId: court.id,
@@ -242,6 +259,7 @@ export function EnhancedBookingCalendar() {
available: !existingBooking,
bookingId: existingBooking?.id,
bookedBy,
partner,
});
});
});
@@ -268,9 +286,7 @@ export function EnhancedBookingCalendar() {
// CRITICAL: Check if there are any active time slots for this day
const dayOfWeek = selectedDateOnly.getDay();
const dayTimeSlots = timeSlots.filter(
slot => slot.dayOfWeek === dayOfWeek && slot.isActive
);
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
// If no time slots are configured for this day, it's not selectable
if (dayTimeSlots.length === 0) return false;
@@ -441,34 +457,55 @@ export function EnhancedBookingCalendar() {
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
{getAvailableDates()
.slice(0, 8)
.map((date, index) => (
<Button
key={index}
variant={
date.toDateString() === selectedDate.toDateString()
? 'default'
: 'outline'
}
size='sm'
onClick={() => setSelectedDate(date)}
className='h-16 flex flex-col'
>
<span className='text-xs font-normal'>
{date.toLocaleDateString('en-US', { weekday: 'short' })}
</span>
<span className='font-semibold'>{date.getDate()}</span>
<span className='text-xs font-normal'>
{date.toLocaleDateString('en-US', { month: 'short' })}
</span>
{isToday(date) && <span className='text-xs text-blue-600'>Today</span>}
</Button>
))}
.map((date, index) => {
const isSelectedDate = date.toDateString() === selectedDate.toDateString();
const isTodayDate = isToday(date);
return (
<Button
key={index}
variant={isSelectedDate ? 'default' : 'outline'}
size='sm'
onClick={() => setSelectedDate(date)}
className={`h-16 flex flex-col relative transition-all ${
isTodayDate && !isSelectedDate
? 'ring-2 ring-blue-400 ring-opacity-50 bg-blue-50 border-blue-200 hover:bg-blue-100'
: ''
} ${
isSelectedDate && isTodayDate
? 'bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800'
: ''
}`}
>
{isTodayDate && (
<div className='absolute -top-1 -right-1 w-3 h-3 bg-orange-500 rounded-full animate-pulse' />
)}
<span className='text-xs font-normal'>
{date.toLocaleDateString('en-US', { weekday: 'short' })}
</span>
<span className='font-semibold'>{date.getDate()}</span>
<span className='text-xs font-normal'>
{date.toLocaleDateString('en-US', { month: 'short' })}
</span>
</Button>
);
})}
</div>
</div>
{/* Selected Date Display */}
<div className='text-center p-4 bg-blue-50 rounded-lg'>
<h3 className='text-lg font-semibold text-blue-900'>
<div
className={`text-center p-4 rounded-lg ${
isToday(selectedDate)
? 'bg-gradient-to-r from-blue-500 to-indigo-600 text-white'
: 'bg-blue-50'
}`}
>
<h3
className={`text-lg font-semibold ${
isToday(selectedDate) ? 'text-white' : 'text-blue-900'
}`}
>
{selectedDate.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
@@ -476,7 +513,13 @@ export function EnhancedBookingCalendar() {
day: 'numeric',
})}
</h3>
{isToday(selectedDate) && <span className='text-sm text-blue-600'>Today</span>}
{isToday(selectedDate) && (
<div className='flex items-center justify-center gap-2 mt-2'>
<div className='w-2 h-2 bg-orange-300 rounded-full animate-pulse' />
<span className='text-sm font-medium text-blue-100'>Today</span>
<div className='w-2 h-2 bg-orange-300 rounded-full animate-pulse' />
</div>
)}
</div>
{/* Loading State */}
@@ -494,53 +537,85 @@ export function EnhancedBookingCalendar() {
</div>
)}
{/* Time Slots Grid */}
{/* Time Slots Grid - Organized by Time */}
{!loading && courts.length > 0 && (
<div className='space-y-4'>
<h3 className='font-medium'>Available Time Slots</h3>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
{bookingSlots.map((slot, index) => (
<div
key={`${slot.courtId}-${slot.time}`}
className={`p-4 border rounded-lg transition-colors cursor-pointer ${
slot.available
? 'border-green-200 bg-green-50 hover:bg-green-100'
: 'border-red-200 bg-red-50 cursor-not-allowed'
}`}
onClick={() => handleSlotClick(slot)}
>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<div className='flex items-center gap-2 text-sm font-medium'>
<div className='space-y-3'>
{/* Group slots by time */}
{Array.from(new Set(bookingSlots.map((slot) => slot.time)))
.sort()
.map((time) => {
const slotsForTime = bookingSlots.filter((slot) => slot.time === time);
return (
<div key={time} className='space-y-2'>
<div className='flex items-center gap-2 text-sm font-medium text-gray-700'>
<Clock className='h-4 w-4' />
{slot.time} -{' '}
{String(parseInt(slot.time.split(':')[0]) + 1).padStart(2, '0')}
:00
{time} -{' '}
{String(parseInt(time.split(':')[0]) + 1).padStart(2, '0')}:00
</div>
<div className='flex items-center gap-2 text-sm text-gray-600'>
<MapPin className='h-4 w-4' />
{slot.courtName}
<div
className={`grid gap-3 ${
slotsForTime.length === 1
? 'grid-cols-1'
: 'grid-cols-1 sm:grid-cols-2'
}`}
>
{slotsForTime.map((slot) => (
<div
key={`${slot.courtId}-${slot.time}`}
className={`p-3 border rounded-lg transition-colors cursor-pointer ${
slot.available
? 'border-green-200 bg-green-50 hover:bg-green-100'
: 'border-red-200 bg-red-50 cursor-not-allowed'
}`}
onClick={() => handleSlotClick(slot)}
>
<div className='flex items-center justify-between'>
<div className='space-y-1 flex-1'>
<div className='flex items-center gap-2 text-sm font-medium'>
<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-red-600'>
<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'>
<User className='h-3 w-3' />
Playing with: {slot.partner}
</div>
)}
</div>
)}
{!slot.available && !slot.bookedBy && (
<div className='text-xs text-red-600'>
Already booked
</div>
)}
</div>
<Button
size='sm'
disabled={!slot.available}
className={
slot.available
? 'bg-green-600 hover:bg-green-700'
: ''
}
>
{slot.available ? 'Book' : 'Booked'}
</Button>
</div>
</div>
))}
</div>
{!slot.available && slot.bookedBy && (
<div className='flex items-center gap-2 text-xs text-red-600'>
<Users className='h-3 w-3' />
Booked by {slot.bookedBy}
</div>
)}
{!slot.available && !slot.bookedBy && (
<div className='text-xs text-red-600'>Already booked</div>
)}
</div>
<Button
size='sm'
disabled={!slot.available}
className={slot.available ? 'bg-green-600 hover:bg-green-700' : ''}
>
{slot.available ? 'Book' : 'Booked'}
</Button>
</div>
</div>
))}
);
})}
</div>
</div>
)}
@@ -554,8 +629,8 @@ export function EnhancedBookingCalendar() {
No courts available on {getDayName(selectedDate.getDay())}s
</div>
<p className='text-gray-500 text-sm'>
This facility is closed on {getDayName(selectedDate.getDay())}s.
Please select a different day to make a booking.
This facility is closed on {getDayName(selectedDate.getDay())}s. Please
select a different day to make a booking.
</p>
</div>
) : (
@@ -268,8 +268,11 @@ export function UserBookingManagement() {
<MapPin className='h-4 w-4 text-blue-600' />
<span className='font-medium text-sm'>{booking.court.name}</span>
{isToday(booking.date) && (
<Badge variant='secondary' className='text-xs'>
Today
<Badge
variant='secondary'
className='text-xs bg-gradient-to-r from-orange-100 to-orange-200 text-orange-700 border-orange-300'
>
🎯 Today
</Badge>
)}
</div>
+5 -1
View File
@@ -15,6 +15,8 @@ interface DashboardHeaderProps {
userId: string;
email: string;
role: 'user' | 'admin';
name?: string;
surname?: string;
};
}
@@ -101,7 +103,9 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
className='flex items-center space-x-2'
>
<User className='h-4 w-4 text-gray-600' />
<span className='text-sm text-gray-700'>{user.email.split('@')[0]}</span>
<span className='text-sm text-gray-700'>
{user.name && user.surname ? `${user.name} ${user.surname}` : user.email.split('@')[0]}
</span>
</Button>
<Button variant='outline' size='sm' onClick={handleLogout} disabled={isLoggingOut}>
+44 -38
View File
@@ -9,13 +9,14 @@ This update introduces two major features to the table tennis booking system:
The system now supports different booking hours for different days of the week. Administrators can configure custom time slots for each day (Sunday through Saturday).
#### Example Configuration:
- **Sunday**: 12:00 - 17:00 (Weekend afternoon sessions)
- **Monday**: 19:00 - 23:00 (Evening sessions only)
- **Tuesday**: 19:00 - 23:00 (Evening sessions only)
- **Wednesday**: 18:00 - 22:00 (Shorter evening sessions)
- **Thursday**: 19:00 - 23:00 (Evening sessions only)
- **Friday**: 18:00 - 22:00 (Shorter evening sessions)
- **Saturday**: 10:00 - 18:00 (Full day weekend sessions)
- **Sunday**: 12:00 - 17:00 (Weekend afternoon sessions)
- **Monday**: 19:00 - 23:00 (Evening sessions only)
- **Tuesday**: 19:00 - 23:00 (Evening sessions only)
- **Wednesday**: 18:00 - 22:00 (Shorter evening sessions)
- **Thursday**: 19:00 - 23:00 (Evening sessions only)
- **Friday**: 18:00 - 22:00 (Shorter evening sessions)
- **Saturday**: 10:00 - 18:00 (Full day weekend sessions)
### 2. User Name Display
@@ -42,36 +43,40 @@ CREATE TABLE time_slots (
### API Endpoints
#### Public Endpoints (for authenticated users):
- `GET /api/time-slots` - Retrieve active time slots for all days
- `GET /api/bookings/all?date=YYYY-MM-DD` - Get all bookings with user and court information
- `GET /api/time-slots` - Retrieve active time slots for all days
- `GET /api/bookings/all?date=YYYY-MM-DD` - Get all bookings with user and court information
#### Admin Endpoints:
- `GET /api/admin/time-slots` - Retrieve all time slots (including inactive)
- `POST /api/admin/time-slots` - Create new time slot
- `PUT /api/admin/time-slots/[id]` - Update existing time slot
- `DELETE /api/admin/time-slots/[id]` - Delete time slot
- `GET /api/admin/time-slots` - Retrieve all time slots (including inactive)
- `POST /api/admin/time-slots` - Create new time slot
- `PUT /api/admin/time-slots/[id]` - Update existing time slot
- `DELETE /api/admin/time-slots/[id]` - Delete time slot
### Admin Management
Administrators can manage time slots through the admin dashboard under the "Settings" tab. The interface allows:
- **Create Time Slots**: Set day of week, start time, end time, and active status
- **Edit Time Slots**: Modify existing time slot configurations
- **Delete Time Slots**: Remove time slot configurations
- **Activate/Deactivate**: Toggle time slots on/off without deletion
- **Create Time Slots**: Set day of week, start time, end time, and active status
- **Edit Time Slots**: Modify existing time slot configurations
- **Delete Time Slots**: Remove time slot configurations
- **Activate/Deactivate**: Toggle time slots on/off without deletion
### User Experience
#### Enhanced Booking Calendar:
- Automatically adapts to show only available time slots for the selected day
- Displays who has booked each unavailable slot
- Maintains mobile-responsive design
- Provides fallback to global settings if no day-specific slots are configured
- Automatically adapts to show only available time slots for the selected day
- Displays who has booked each unavailable slot
- Maintains mobile-responsive design
- Provides fallback to global settings if no day-specific slots are configured
#### Booking Display:
- Available slots: Green background with "Book" button
- Booked slots: Red background showing "Booked by [Full Name]"
- Clear visual distinction between available and booked slots
- Available slots: Green background with "Book" button
- Booked slots: Red background showing "Booked by [Full Name]"
- Clear visual distinction between available and booked slots
## Usage Instructions
@@ -79,10 +84,10 @@ Administrators can manage time slots through the admin dashboard under the "Sett
1. **Navigate to Admin Dashboard** → Settings tab
2. **Time Slot Management section** allows you to:
- Add new time slots for specific days
- Edit existing time slot configurations
- View all time slots organized by day of the week
- Activate/deactivate time slots as needed
- Add new time slots for specific days
- Edit existing time slot configurations
- View all time slots organized by day of the week
- Activate/deactivate time slots as needed
### For Users:
@@ -93,11 +98,11 @@ Administrators can manage time slots through the admin dashboard under the "Sett
## Technical Benefits
- **Flexible Scheduling**: Different operational hours for different days
- **User Transparency**: Know who's playing when for coordination
- **Administrative Control**: Easy management of time slots
- **Backward Compatibility**: Maintains fallback to global settings
- **Mobile Optimized**: Responsive design across all devices
- **Flexible Scheduling**: Different operational hours for different days
- **User Transparency**: Know who's playing when for coordination
- **Administrative Control**: Easy management of time slots
- **Backward Compatibility**: Maintains fallback to global settings
- **Mobile Optimized**: Responsive design across all devices
## Migration
@@ -106,8 +111,9 @@ The system includes database seeding scripts that populate initial time slots ba
## Future Enhancements
Potential future improvements could include:
- Seasonal time slot variations
- Holiday-specific scheduling
- Automatic time slot generation tools
- Bulk time slot operations
- Time slot templates for quick setup
- Seasonal time slot variations
- Holiday-specific scheduling
- Automatic time slot generation tools
- Bulk time slot operations
- Time slot templates for quick setup
+79 -59
View File
@@ -1,28 +1,33 @@
# ✅ ROBUST BOOKING VALIDATION IMPLEMENTATION COMPLETE
## 🎯 **PROBLEM SOLVED**
**Original Issue**: "On days where there is no booking slots(e.g. none set up, no play that day), system just gives all of the options to the clients. Robust checking has to be in place to not allow clients ever try to book something that is not available, not via UI, not via API"
## 🛡️ **COMPREHENSIVE VALIDATION LAYERS**
### **1. Database Layer ✅**
- **Time Slots Configuration**: Proper day-specific time slots in database
- **Current Configuration**:
- Sunday: 12:00-17:00
- Monday: 19:00-23:00
- Tuesday: 19:00-23:00
- **Wednesday: CLOSED** (no time slots)
- **Thursday: CLOSED** (no time slots)
- Friday: 18:00-22:00
- Saturday: 10:00-18:00
- **Time Slots Configuration**: Proper day-specific time slots in database
- **Current Configuration**:
- Sunday: 12:00-17:00
- Monday: 19:00-23:00
- Tuesday: 19:00-23:00
- **Wednesday: CLOSED** (no time slots)
- **Thursday: CLOSED** (no time slots)
- Friday: 18:00-22:00
- Saturday: 10:00-18:00
### **2. API Layer Validation ✅**
**File**: `/app/api/bookings/route.ts`
-**Day Validation**: Rejects bookings on days with no time slots
-**Time Validation**: Rejects bookings outside allowed time ranges
-**Detailed Error Messages**: Specific feedback for different validation failures
- **Day Validation**: Rejects bookings on days with no time slots
- **Time Validation**: Rejects bookings outside allowed time ranges
-**Detailed Error Messages**: Specific feedback for different validation failures
**Example API Responses**:
```json
// Booking on Wednesday (closed day)
{
@@ -36,80 +41,93 @@
```
### **3. UI Layer Validation ✅**
**File**: `/components/booking/enhanced-booking-calendar.tsx`
#### **Date Selection Prevention**:
-`isDateSelectable()` function prevents selecting unavailable days
- ✅ Calendar disables days with no time slots
- ✅ Users cannot click on closed days
- `isDateSelectable()` function prevents selecting unavailable days
- ✅ Calendar disables days with no time slots
- ✅ Users cannot click on closed days
#### **Time Slot Generation**:
-`generateTimeSlots()` only shows available times for selected day
- **NO FALLBACK** to global settings - returns empty array if no day-specific slots
- `isDayBookable()` function checks if day has any active time slots
- `generateTimeSlots()` only shows available times for selected day
- **NO FALLBACK** to global settings - returns empty array if no day-specific slots
-`isDayBookable()` function checks if day has any active time slots
#### **Visual Feedback**:
- ✅ Clear messages: "No courts available on Wednesdays"
- ✅ Explains facility is closed on that day
- ✅ Shows who booked each unavailable slot
- ✅ Clear messages: "No courts available on Wednesdays"
- ✅ Explains facility is closed on that day
- ✅ Shows who booked each unavailable slot
#### **Multiple Validation Points**:
-`handleSlotClick()` - Prevents booking dialog on invalid slots
-`handleBookingConfirm()` - Final validation before API call
- ✅ Toast notifications for validation failures
- `handleSlotClick()` - Prevents booking dialog on invalid slots
- `handleBookingConfirm()` - Final validation before API call
- ✅ Toast notifications for validation failures
### **4. User Experience Features ✅**
#### **Day-Specific Booking Times**:
- ✅ Different hours for different days of the week
- ✅ Admin can configure via Time Slot Management interface
- ✅ Automatic calendar adaptation based on selected date
- ✅ Different hours for different days of the week
- ✅ Admin can configure via Time Slot Management interface
- ✅ Automatic calendar adaptation based on selected date
#### **Enhanced Booking Display**:
- ✅ Shows "Booked by [Full Name]" instead of just "Booked"
- `/api/bookings/all` endpoint includes user information
- ✅ Clear visual distinction between available/unavailable slots
- ✅ Shows "Booked by [Full Name]" instead of just "Booked"
- `/api/bookings/all` endpoint includes user information
- ✅ Clear visual distinction between available/unavailable slots
## 🧪 **VALIDATION TEST SCENARIOS**
The system now prevents ALL of these invalid booking attempts:
1. **❌ Booking on Closed Days**
- UI: Date not selectable, clear "facility closed" message
- API: "No bookings are allowed on Wednesdays"
- UI: Date not selectable, clear "facility closed" message
- API: "No bookings are allowed on Wednesdays"
2. **❌ Booking at Wrong Times**
- UI: Time slot not generated, not displayed
- API: "Time slot 10:00 is not available on Mondays"
- UI: Time slot not generated, not displayed
- API: "Time slot 10:00 is not available on Mondays"
3. **❌ Direct API Attacks**
- Comprehensive server-side validation
- Detailed error messages for debugging
- No way to bypass UI restrictions
- Comprehensive server-side validation
- Detailed error messages for debugging
- No way to bypass UI restrictions
4. **✅ Valid Bookings Only**
- Only shows available times for bookable days
- Only allows clicks on valid time slots
- Only processes API calls for valid day/time combinations
- Only shows available times for bookable days
- Only allows clicks on valid time slots
- Only processes API calls for valid day/time combinations
## 🎯 **SECURITY GUARANTEES**
### **Zero Bypass Paths**:
- ✅ Users cannot select unavailable dates in calendar
- ✅ Users cannot see unavailable time slots
- ✅ Users cannot click on invalid slots
- ✅ Users cannot submit booking forms for invalid times
- ✅ API rejects all invalid booking attempts with specific errors
- ✅ Users cannot select unavailable dates in calendar
- ✅ Users cannot see unavailable time slots
- ✅ Users cannot click on invalid slots
- ✅ Users cannot submit booking forms for invalid times
- ✅ API rejects all invalid booking attempts with specific errors
### **Admin Control**:
- ✅ Complete control over which days have courts available
- ✅ Flexible time ranges per day
- ✅ Easy enable/disable of specific time slots
- ✅ Activity logging of all time slot changes
- ✅ Complete control over which days have courts available
- ✅ Flexible time ranges per day
- ✅ Easy enable/disable of specific time slots
- ✅ Activity logging of all time slot changes
## 📋 **IMPLEMENTATION FILES**
### **Modified/Created Files**:
1.`/app/api/bookings/route.ts` - Server-side validation
2.`/components/booking/enhanced-booking-calendar.tsx` - UI validation
3.`/app/api/time-slots/route.ts` - Public time slots API
@@ -119,19 +137,21 @@ The system now prevents ALL of these invalid booking attempts:
7. ✅ Database schema with proper time_slots table
### **Validation Functions**:
-`isDayBookable()` - Checks if day has any time slots
-`isDateSelectable()` - Prevents selecting unavailable dates
-`generateTimeSlots()` - Only returns valid times for day
- ✅ Server-side day/time validation in booking API
- `isDayBookable()` - Checks if day has any time slots
- `isDateSelectable()` - Prevents selecting unavailable dates
- `generateTimeSlots()` - Only returns valid times for day
- ✅ Server-side day/time validation in booking API
## 🚀 **RESULT**
**PROBLEM COMPLETELY SOLVED**:
- ❌ Users can NO LONGER book on days without time slots
- ❌ Users can NO LONGER book at unavailable times
- ❌ No fallback to global settings - strict day-specific enforcement
- ✅ Clear communication about facility availability
- ✅ Robust validation at every layer (UI, API, Database)
- ✅ Enhanced UX with user names and day-specific times
- ❌ Users can NO LONGER book on days without time slots
- ❌ Users can NO LONGER book at unavailable times
- ❌ No fallback to global settings - strict day-specific enforcement
- ✅ Clear communication about facility availability
- ✅ Robust validation at every layer (UI, API, Database)
- ✅ Enhanced UX with user names and day-specific times
The system is now **bulletproof** against invalid booking attempts through any channel.
@@ -0,0 +1,8 @@
CREATE TABLE `metrics` (
`id` text PRIMARY KEY NOT NULL,
`metric_type` text NOT NULL,
`period` text NOT NULL,
`value` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
+549
View File
@@ -0,0 +1,549 @@
{
"version": "5",
"dialect": "sqlite",
"id": "b6ff7034-4299-4b61-8a16-3b46eae7b4ef",
"prevId": "55393d37-4cdf-45ba-aa6a-1e50b082b57c",
"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": {}
},
"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'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1758575829475,
"tag": "0000_tidy_kang",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1758824962110,
"tag": "0001_slimy_starjammers",
"breakpoints": true
}
]
}
+14
View File
@@ -91,6 +91,16 @@ export const activityLogs = sqliteTable('activity_logs', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
// Metrics table for tracking monthly statistics
export const metrics = sqliteTable('metrics', {
id: text('id').primaryKey(),
metricType: text('metric_type').notNull(), // e.g., 'monthly_bookings'
period: text('period').notNull(), // e.g., '2025-09' for September 2025
value: integer('value').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Zod schemas for validation
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);
@@ -106,6 +116,8 @@ export const insertSettingSchema = createInsertSchema(settings);
export const selectSettingSchema = createSelectSchema(settings);
export const insertActivityLogSchema = createInsertSchema(activityLogs);
export const selectActivityLogSchema = createSelectSchema(activityLogs);
export const insertMetricSchema = createInsertSchema(metrics);
export const selectMetricSchema = createSelectSchema(metrics);
// Types
export type User = typeof users.$inferSelect;
@@ -122,3 +134,5 @@ export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert;
export type ActivityLog = typeof activityLogs.$inferSelect;
export type NewActivityLog = typeof activityLogs.$inferInsert;
export type Metric = typeof metrics.$inferSelect;
export type NewMetric = typeof metrics.$inferInsert;
+78
View File
@@ -0,0 +1,78 @@
import { db } from '../lib/db';
import { settings, metrics } from '../lib/db/schema';
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
async function initializeAdminData() {
try {
console.log('Initializing admin dashboard data...');
const now = new Date();
// Initialize default settings
console.log('Setting up default settings...');
const defaultSettings = [
{
id: randomUUID(),
key: 'booking_window_days',
value: '14',
updatedAt: now,
},
{
id: randomUUID(),
key: 'max_booking_duration_hours',
value: '2',
updatedAt: now,
},
{
id: randomUUID(),
key: 'max_bookings_per_user_per_hour_per_day',
value: '1',
updatedAt: now,
},
];
// Insert settings if they don't exist
for (const setting of defaultSettings) {
const existingSetting = await db.select().from(settings).where(eq(settings.key, setting.key)).limit(1);
if (existingSetting.length === 0) {
await db.insert(settings).values(setting);
console.log(`✓ Setting '${setting.key}' initialized with value '${setting.value}'`);
} else {
console.log(`- Setting '${setting.key}' already exists`);
}
}
// Initialize current month's metrics
console.log('Initializing monthly metrics...');
const currentMonth = now.toISOString().substring(0, 7); // "2025-09"
const existingMetric = await db.select().from(metrics).where(eq(metrics.period, currentMonth)).limit(1);
if (existingMetric.length === 0) {
const monthlyMetric = {
id: randomUUID(),
metricType: 'monthly_bookings',
period: currentMonth,
value: 0,
createdAt: now,
updatedAt: now,
};
await db.insert(metrics).values(monthlyMetric);
console.log(`✓ Monthly metrics initialized for ${currentMonth}`);
} else {
console.log(`- Monthly metrics for ${currentMonth} already exist`);
}
console.log('Admin dashboard data initialization complete!');
} catch (error) {
console.error('Error initializing admin data:', error);
throw error;
}
}
initializeAdminData();