additional features, refinement and more control over the app from admin side, better bookings UX
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]$/;
|
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||||
if (startTime && !timeRegex.test(startTime)) {
|
if (startTime && !timeRegex.test(startTime)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Invalid startTime format. Use HH:MM format' }, { status: 400 });
|
||||||
{ error: 'Invalid startTime format. Use HH:MM format' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endTime && !timeRegex.test(endTime)) {
|
if (endTime && !timeRegex.test(endTime)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Invalid endTime format. Use HH:MM format' }, { status: 400 });
|
||||||
{ error: 'Invalid endTime format. Use HH:MM format' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTimeSlot = await db
|
const updatedTimeSlot = await db
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTimeSlots = await db
|
const allTimeSlots = await db.select().from(timeSlots).orderBy(timeSlots.dayOfWeek, timeSlots.startTime);
|
||||||
.select()
|
|
||||||
.from(timeSlots)
|
|
||||||
.orderBy(timeSlots.dayOfWeek, timeSlots.startTime);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
timeSlots: allTimeSlots,
|
timeSlots: allTimeSlots,
|
||||||
@@ -53,10 +50,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Validate time format (HH:MM)
|
// Validate time format (HH:MM)
|
||||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||||
if (!timeRegex.test(startTime) || !timeRegex.test(endTime)) {
|
if (!timeRegex.test(startTime) || !timeRegex.test(endTime)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Invalid time format. Use HH:MM format' }, { status: 400 });
|
||||||
{ error: 'Invalid time format. Use HH:MM format' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTimeSlot = await db
|
const newTimeSlot = await db
|
||||||
|
|||||||
+77
-12
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
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 { eq, and } from 'drizzle-orm';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
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 });
|
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) {
|
if (!courtId || !date || !timeSlot) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -91,18 +91,15 @@ export async function POST(request: NextRequest) {
|
|||||||
const availableTimeSlots = await db
|
const availableTimeSlots = await db
|
||||||
.select()
|
.select()
|
||||||
.from(timeSlots)
|
.from(timeSlots)
|
||||||
.where(
|
.where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
|
||||||
and(
|
|
||||||
eq(timeSlots.dayOfWeek, dayOfWeek),
|
|
||||||
eq(timeSlots.isActive, true)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if any time slots are configured for this day
|
// Check if any time slots are configured for this day
|
||||||
if (availableTimeSlots.length === 0) {
|
if (availableTimeSlots.length === 0) {
|
||||||
return NextResponse.json(
|
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 }
|
{ 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
|
// Check if the requested time slot is within any of the allowed time ranges
|
||||||
const requestedHour = parseInt(startTime.split(':')[0]);
|
const requestedHour = parseInt(startTime.split(':')[0]);
|
||||||
const isTimeSlotValid = availableTimeSlots.some(slot => {
|
const isTimeSlotValid = availableTimeSlots.some((slot) => {
|
||||||
const slotStartHour = parseInt(slot.startTime.split(':')[0]);
|
const slotStartHour = parseInt(slot.startTime.split(':')[0]);
|
||||||
const slotEndHour = parseInt(slot.endTime.split(':')[0]);
|
const slotEndHour = parseInt(slot.endTime.split(':')[0]);
|
||||||
return requestedHour >= slotStartHour && requestedHour < slotEndHour;
|
return requestedHour >= slotStartHour && requestedHour < slotEndHour;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isTimeSlotValid) {
|
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(
|
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 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
@@ -160,6 +190,7 @@ export async function POST(request: NextRequest) {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
notes: notes || null, // Include notes from the request
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -181,6 +212,40 @@ export async function POST(request: NextRequest) {
|
|||||||
request,
|
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({
|
return NextResponse.json({
|
||||||
booking: newBooking,
|
booking: newBooking,
|
||||||
message: 'Booking created successfully',
|
message: 'Booking created successfully',
|
||||||
|
|||||||
+30
-2
@@ -1,5 +1,8 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getSession } from '@/lib/session';
|
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 { DashboardHeader } from '@/components/dashboard/dashboard-header';
|
||||||
import { EnhancedBookingCalendar } from '@/components/booking/enhanced-booking-calendar';
|
import { EnhancedBookingCalendar } from '@/components/booking/enhanced-booking-calendar';
|
||||||
import { UserBookingManagement } from '@/components/booking/user-booking-management';
|
import { UserBookingManagement } from '@/components/booking/user-booking-management';
|
||||||
@@ -11,9 +14,32 @@ export default async function DashboardPage() {
|
|||||||
redirect('/login');
|
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 (
|
return (
|
||||||
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100'>
|
<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'>
|
<main className='container mx-auto px-4 py-8'>
|
||||||
<div className='grid gap-8 lg:grid-cols-3'>
|
<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 className='lg:col-span-2 space-y-6'>
|
||||||
<div>
|
<div>
|
||||||
<h1 className='text-3xl font-bold text-gray-900 mb-2'>
|
<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>
|
</h1>
|
||||||
<p className='text-gray-600'>Book your table tennis court and enjoy your game</p>
|
<p className='text-gray-600'>Book your table tennis court and enjoy your game</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface SettingsData {
|
|||||||
booking_start_time: string;
|
booking_start_time: string;
|
||||||
booking_end_time: string;
|
booking_end_time: string;
|
||||||
allow_weekend_bookings: string;
|
allow_weekend_bookings: string;
|
||||||
|
max_bookings_per_user_per_hour_per_day: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSettingsManagement() {
|
export function AdminSettingsManagement() {
|
||||||
@@ -33,6 +34,7 @@ export function AdminSettingsManagement() {
|
|||||||
booking_start_time: '08:00',
|
booking_start_time: '08:00',
|
||||||
booking_end_time: '22:00',
|
booking_end_time: '22:00',
|
||||||
allow_weekend_bookings: 'true',
|
allow_weekend_bookings: 'true',
|
||||||
|
max_bookings_per_user_per_hour_per_day: '1',
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -54,6 +56,7 @@ export function AdminSettingsManagement() {
|
|||||||
booking_start_time: '08:00',
|
booking_start_time: '08:00',
|
||||||
booking_end_time: '22:00',
|
booking_end_time: '22:00',
|
||||||
allow_weekend_bookings: 'true',
|
allow_weekend_bookings: 'true',
|
||||||
|
max_bookings_per_user_per_hour_per_day: '1',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map the settings array to our object
|
// 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>
|
<p className='text-sm text-gray-500'>When courts close for booking each day</p>
|
||||||
</div>
|
</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 */}
|
{/* Weekend Bookings */}
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div className='flex items-center space-x-2'>
|
<div className='flex items-center space-x-2'>
|
||||||
@@ -286,6 +303,10 @@ export function AdminSettingsManagement() {
|
|||||||
<strong>Weekend Bookings:</strong>{' '}
|
<strong>Weekend Bookings:</strong>{' '}
|
||||||
{settings.allow_weekend_bookings === 'true' ? 'Enabled' : 'Disabled'}
|
{settings.allow_weekend_bookings === 'true' ? 'Enabled' : 'Disabled'}
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Booking Limit:</strong> {settings.max_bookings_per_user_per_hour_per_day} per
|
||||||
|
hour
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,15 +21,7 @@ interface TimeSlot {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAYS = [
|
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
'Sunday',
|
|
||||||
'Monday',
|
|
||||||
'Tuesday',
|
|
||||||
'Wednesday',
|
|
||||||
'Thursday',
|
|
||||||
'Friday',
|
|
||||||
'Saturday'
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AdminTimeSlotManagement() {
|
export function AdminTimeSlotManagement() {
|
||||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||||
@@ -80,9 +72,7 @@ export function AdminTimeSlotManagement() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const url = editingSlot
|
const url = editingSlot ? `/api/admin/time-slots/${editingSlot.id}` : '/api/admin/time-slots';
|
||||||
? `/api/admin/time-slots/${editingSlot.id}`
|
|
||||||
: '/api/admin/time-slots';
|
|
||||||
|
|
||||||
const method = editingSlot ? 'PUT' : 'POST';
|
const method = editingSlot ? 'PUT' : 'POST';
|
||||||
|
|
||||||
@@ -97,9 +87,7 @@ export function AdminTimeSlotManagement() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
description: editingSlot
|
description: editingSlot ? 'Time slot updated successfully' : 'Time slot created successfully',
|
||||||
? 'Time slot updated successfully'
|
|
||||||
: 'Time slot created successfully',
|
|
||||||
});
|
});
|
||||||
fetchTimeSlots();
|
fetchTimeSlots();
|
||||||
setShowDialog(false);
|
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) => {
|
const handleEdit = (slot: TimeSlot) => {
|
||||||
setEditingSlot(slot);
|
setEditingSlot(slot);
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -193,27 +226,25 @@ export function AdminTimeSlotManagement() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center">
|
<div className='flex justify-between items-center'>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className='flex items-center gap-2'>
|
||||||
<Clock className="h-5 w-5" />
|
<Clock className='h-5 w-5' />
|
||||||
Time Slot Management
|
Time Slot Management
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button onClick={resetForm}>
|
<Button onClick={resetForm}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className='h-4 w-4 mr-2' />
|
||||||
Add Time Slot
|
Add Time Slot
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{editingSlot ? 'Edit Time Slot' : 'Add New Time Slot'}</DialogTitle>
|
||||||
{editingSlot ? 'Edit Time Slot' : 'Add New Time Slot'}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="dayOfWeek">Day of Week</Label>
|
<Label htmlFor='dayOfWeek'>Day of Week</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.dayOfWeek.toString()}
|
value={formData.dayOfWeek.toString()}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -221,7 +252,7 @@ export function AdminTimeSlotManagement() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select day" />
|
<SelectValue placeholder='Select day' />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DAYS.map((day, index) => (
|
{DAYS.map((day, index) => (
|
||||||
@@ -233,48 +264,38 @@ export function AdminTimeSlotManagement() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="startTime">Start Time</Label>
|
<Label htmlFor='startTime'>Start Time</Label>
|
||||||
<Input
|
<Input
|
||||||
id="startTime"
|
id='startTime'
|
||||||
type="time"
|
type='time'
|
||||||
value={formData.startTime}
|
value={formData.startTime}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
|
||||||
setFormData({ ...formData, startTime: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="endTime">End Time</Label>
|
<Label htmlFor='endTime'>End Time</Label>
|
||||||
<Input
|
<Input
|
||||||
id="endTime"
|
id='endTime'
|
||||||
type="time"
|
type='time'
|
||||||
value={formData.endTime}
|
value={formData.endTime}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
|
||||||
setFormData({ ...formData, endTime: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className='flex items-center space-x-2'>
|
||||||
<Switch
|
<Switch
|
||||||
id="isActive"
|
id='isActive'
|
||||||
checked={formData.isActive}
|
checked={formData.isActive}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||||
setFormData({ ...formData, isActive: checked })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="isActive">Active</Label>
|
<Label htmlFor='isActive'>Active</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end space-x-2">
|
<div className='flex justify-end space-x-2'>
|
||||||
<Button
|
<Button type='button' variant='outline' onClick={() => setShowDialog(false)}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDialog(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type='submit' disabled={loading}>
|
||||||
{loading ? 'Saving...' : editingSlot ? 'Update' : 'Create'}
|
{loading ? 'Saving...' : editingSlot ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,57 +306,75 @@ export function AdminTimeSlotManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && timeSlots.length === 0 ? (
|
{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) => (
|
{DAYS.map((day, dayIndex) => (
|
||||||
<div key={dayIndex} className="space-y-2">
|
<div key={dayIndex} className='space-y-2'>
|
||||||
<h3 className="font-semibold text-lg">{day}</h3>
|
<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 ? (
|
{groupedTimeSlots[dayIndex]?.length > 0 ? (
|
||||||
<div className="grid gap-2">
|
<div className='grid gap-2'>
|
||||||
{groupedTimeSlots[dayIndex]
|
{groupedTimeSlots[dayIndex]
|
||||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||||
.map((slot) => (
|
.map((slot) => (
|
||||||
<div
|
<div
|
||||||
key={slot.id}
|
key={slot.id}
|
||||||
className={`flex items-center justify-between p-3 border rounded-lg ${
|
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='flex items-center space-x-3'>
|
||||||
<div className="font-medium">
|
<div className='font-medium'>
|
||||||
{slot.startTime} - {slot.endTime}
|
{slot.startTime} - {slot.endTime}
|
||||||
</div>
|
</div>
|
||||||
<div className={`px-2 py-1 rounded-full text-xs ${
|
<div
|
||||||
|
className={`px-2 py-1 rounded-full text-xs ${
|
||||||
slot.isActive
|
slot.isActive
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: 'bg-gray-100 text-gray-800'
|
: 'bg-gray-100 text-gray-800'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{slot.isActive ? 'Active' : 'Inactive'}
|
{slot.isActive ? 'Active' : 'Inactive'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className='flex space-x-2'>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size='sm'
|
||||||
variant="outline"
|
variant='outline'
|
||||||
onClick={() => handleEdit(slot)}
|
onClick={() => handleEdit(slot)}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className='h-4 w-4' />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size='sm'
|
||||||
variant="outline"
|
variant='outline'
|
||||||
onClick={() => handleDelete(slot.id)}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -71,8 +71,12 @@ export function AdminUserManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateUser = async () => {
|
const handleCreateUser = async (e?: React.FormEvent) => {
|
||||||
try {
|
try {
|
||||||
|
// Prevent form submission and double submissions
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
if (!formData.name || !formData.surname || !formData.email || !formData.password) {
|
if (!formData.name || !formData.surname || !formData.email || !formData.password) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@@ -82,6 +86,8 @@ export function AdminUserManagement() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
const response = await fetch('/api/admin/users', {
|
const response = await fetch('/api/admin/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -114,11 +120,16 @@ export function AdminUserManagement() {
|
|||||||
description: 'Failed to create user',
|
description: 'Failed to create user',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditUser = async () => {
|
const handleEditUser = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Prevent double submissions
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
if (!editingUser || !formData.name || !formData.surname || !formData.email) {
|
if (!editingUser || !formData.name || !formData.surname || !formData.email) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@@ -128,6 +139,8 @@ export function AdminUserManagement() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
const updateData = { ...formData };
|
const updateData = { ...formData };
|
||||||
if (!updateData.password) {
|
if (!updateData.password) {
|
||||||
delete updateData.password; // Don't update password if not provided
|
delete updateData.password; // Don't update password if not provided
|
||||||
@@ -166,6 +179,8 @@ export function AdminUserManagement() {
|
|||||||
description: 'Failed to update user',
|
description: 'Failed to update user',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,7 +277,7 @@ export function AdminUserManagement() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create New User</DialogTitle>
|
<DialogTitle>Create New User</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='space-y-4'>
|
<form onSubmit={handleCreateUser} className='space-y-4'>
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor='name'>First Name</Label>
|
<Label htmlFor='name'>First Name</Label>
|
||||||
@@ -321,12 +336,14 @@ export function AdminUserManagement() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2 justify-end'>
|
<div className='flex gap-2 justify-end'>
|
||||||
<Button variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
|
<Button type='button' variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreateUser}>Create User</Button>
|
<Button type='submit' disabled={loading}>
|
||||||
</div>
|
{loading ? 'Creating...' : 'Create User'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,7 +491,9 @@ export function AdminUserManagement() {
|
|||||||
<Button variant='outline' onClick={() => setIsEditDialogOpen(false)}>
|
<Button variant='outline' onClick={() => setIsEditDialogOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleEditUser}>Update User</Button>
|
<Button onClick={handleEditUser} disabled={loading}>
|
||||||
|
{loading ? 'Updating...' : 'Update User'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -15,14 +15,52 @@ import { AdminCourtManagement } from './AdminCourtManagement';
|
|||||||
import { AdminSettingsManagement } from './AdminSettingsManagement';
|
import { AdminSettingsManagement } from './AdminSettingsManagement';
|
||||||
import { AdminTimeSlotManagement } from './AdminTimeSlotManagement';
|
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() {
|
export function AdminDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [stats] = useState({
|
const [stats, setStats] = useState<AdminStats>({
|
||||||
totalUsers: 125,
|
totalUsers: 0,
|
||||||
todayBookings: 18,
|
activeCourts: 0,
|
||||||
totalCourts: 2,
|
todaysBookings: 0,
|
||||||
weeklyRevenue: 850,
|
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 () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -68,19 +106,8 @@ export function AdminDashboard() {
|
|||||||
<Users className='h-4 w-4 text-muted-foreground' />
|
<Users className='h-4 w-4 text-muted-foreground' />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className='text-2xl font-bold'>{stats.totalUsers}</div>
|
<div className='text-2xl font-bold'>{loading ? '...' : stats.totalUsers}</div>
|
||||||
<p className='text-xs text-muted-foreground'>+12% from last month</p>
|
<p className='text-xs text-muted-foreground'>Registered users</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -90,19 +117,30 @@ export function AdminDashboard() {
|
|||||||
<MapPin className='h-4 w-4 text-muted-foreground' />
|
<MapPin className='h-4 w-4 text-muted-foreground' />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className='text-2xl font-bold'>{stats.totalCourts}</div>
|
<div className='text-2xl font-bold'>{loading ? '...' : stats.activeCourts}</div>
|
||||||
<p className='text-xs text-muted-foreground'>All courts operational</p>
|
<p className='text-xs text-muted-foreground'>Available for booking</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
<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' />
|
<BarChart3 className='h-4 w-4 text-muted-foreground' />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className='text-2xl font-bold'>${stats.weeklyRevenue}</div>
|
<div className='text-2xl font-bold'>{loading ? '...' : stats.monthlyBookings}</div>
|
||||||
<p className='text-xs text-muted-foreground'>+8% from last week</p>
|
<p className='text-xs text-muted-foreground'>This month's total</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +165,7 @@ export function AdminDashboard() {
|
|||||||
<AdminCourtManagement />
|
<AdminCourtManagement />
|
||||||
</TabsContent>{' '}
|
</TabsContent>{' '}
|
||||||
<TabsContent value='settings'>
|
<TabsContent value='settings'>
|
||||||
<div className="space-y-6">
|
<div className='space-y-6'>
|
||||||
<AdminSettingsManagement />
|
<AdminSettingsManagement />
|
||||||
<AdminTimeSlotManagement />
|
<AdminTimeSlotManagement />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface BookingSlot {
|
|||||||
available: boolean;
|
available: boolean;
|
||||||
bookingId?: string;
|
bookingId?: string;
|
||||||
bookedBy?: string;
|
bookedBy?: string;
|
||||||
|
partner?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimeSlot {
|
interface TimeSlot {
|
||||||
@@ -178,14 +179,12 @@ export function EnhancedBookingCalendar() {
|
|||||||
const dayOfWeek = selectedDate.getDay();
|
const dayOfWeek = selectedDate.getDay();
|
||||||
|
|
||||||
// Get time slots for the selected day
|
// Get time slots for the selected day
|
||||||
const dayTimeSlots = timeSlots.filter(
|
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
|
||||||
slot => slot.dayOfWeek === dayOfWeek && slot.isActive
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dayTimeSlots.length > 0) {
|
if (dayTimeSlots.length > 0) {
|
||||||
// Use day-specific time slots
|
// Use day-specific time slots
|
||||||
const slots: string[] = [];
|
const slots: string[] = [];
|
||||||
dayTimeSlots.forEach(timeSlot => {
|
dayTimeSlots.forEach((timeSlot) => {
|
||||||
const start = parseInt(timeSlot.startTime.split(':')[0]);
|
const start = parseInt(timeSlot.startTime.split(':')[0]);
|
||||||
const end = parseInt(timeSlot.endTime.split(':')[0]);
|
const end = parseInt(timeSlot.endTime.split(':')[0]);
|
||||||
|
|
||||||
@@ -205,9 +204,7 @@ export function EnhancedBookingCalendar() {
|
|||||||
|
|
||||||
const isDayBookable = (): boolean => {
|
const isDayBookable = (): boolean => {
|
||||||
const dayOfWeek = selectedDate.getDay();
|
const dayOfWeek = selectedDate.getDay();
|
||||||
const dayTimeSlots = timeSlots.filter(
|
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
|
||||||
slot => slot.dayOfWeek === dayOfWeek && slot.isActive
|
|
||||||
);
|
|
||||||
return dayTimeSlots.length > 0;
|
return dayTimeSlots.length > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,6 +213,24 @@ export function EnhancedBookingCalendar() {
|
|||||||
return days[dayOfWeek];
|
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 generateBookingSlots = (existingBookings: Booking[]) => {
|
||||||
const dateStr = selectedDate.toISOString().split('T')[0];
|
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||||
const timeSlots = generateTimeSlots();
|
const timeSlots = generateTimeSlots();
|
||||||
@@ -235,6 +250,8 @@ export function EnhancedBookingCalendar() {
|
|||||||
? `${existingBooking.user.name} ${existingBooking.user.surname}`
|
? `${existingBooking.user.name} ${existingBooking.user.surname}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const { partner } = parseBookingNotes(existingBooking?.notes);
|
||||||
|
|
||||||
slots.push({
|
slots.push({
|
||||||
time,
|
time,
|
||||||
courtId: court.id,
|
courtId: court.id,
|
||||||
@@ -242,6 +259,7 @@ export function EnhancedBookingCalendar() {
|
|||||||
available: !existingBooking,
|
available: !existingBooking,
|
||||||
bookingId: existingBooking?.id,
|
bookingId: existingBooking?.id,
|
||||||
bookedBy,
|
bookedBy,
|
||||||
|
partner,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -268,9 +286,7 @@ export function EnhancedBookingCalendar() {
|
|||||||
|
|
||||||
// CRITICAL: Check if there are any active time slots for this day
|
// CRITICAL: Check if there are any active time slots for this day
|
||||||
const dayOfWeek = selectedDateOnly.getDay();
|
const dayOfWeek = selectedDateOnly.getDay();
|
||||||
const dayTimeSlots = timeSlots.filter(
|
const dayTimeSlots = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek && slot.isActive);
|
||||||
slot => slot.dayOfWeek === dayOfWeek && slot.isActive
|
|
||||||
);
|
|
||||||
|
|
||||||
// If no time slots are configured for this day, it's not selectable
|
// If no time slots are configured for this day, it's not selectable
|
||||||
if (dayTimeSlots.length === 0) return false;
|
if (dayTimeSlots.length === 0) return false;
|
||||||
@@ -441,18 +457,29 @@ export function EnhancedBookingCalendar() {
|
|||||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||||
{getAvailableDates()
|
{getAvailableDates()
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.map((date, index) => (
|
.map((date, index) => {
|
||||||
|
const isSelectedDate = date.toDateString() === selectedDate.toDateString();
|
||||||
|
const isTodayDate = isToday(date);
|
||||||
|
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={index}
|
key={index}
|
||||||
variant={
|
variant={isSelectedDate ? 'default' : 'outline'}
|
||||||
date.toDateString() === selectedDate.toDateString()
|
|
||||||
? 'default'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => setSelectedDate(date)}
|
onClick={() => setSelectedDate(date)}
|
||||||
className='h-16 flex flex-col'
|
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'>
|
<span className='text-xs font-normal'>
|
||||||
{date.toLocaleDateString('en-US', { weekday: 'short' })}
|
{date.toLocaleDateString('en-US', { weekday: 'short' })}
|
||||||
</span>
|
</span>
|
||||||
@@ -460,15 +487,25 @@ export function EnhancedBookingCalendar() {
|
|||||||
<span className='text-xs font-normal'>
|
<span className='text-xs font-normal'>
|
||||||
{date.toLocaleDateString('en-US', { month: 'short' })}
|
{date.toLocaleDateString('en-US', { month: 'short' })}
|
||||||
</span>
|
</span>
|
||||||
{isToday(date) && <span className='text-xs text-blue-600'>Today</span>}
|
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Date Display */}
|
{/* Selected Date Display */}
|
||||||
<div className='text-center p-4 bg-blue-50 rounded-lg'>
|
<div
|
||||||
<h3 className='text-lg font-semibold text-blue-900'>
|
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', {
|
{selectedDate.toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -476,7 +513,13 @@ export function EnhancedBookingCalendar() {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
@@ -494,15 +537,35 @@ export function EnhancedBookingCalendar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time Slots Grid */}
|
{/* Time Slots Grid - Organized by Time */}
|
||||||
{!loading && courts.length > 0 && (
|
{!loading && courts.length > 0 && (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<h3 className='font-medium'>Available Time Slots</h3>
|
<h3 className='font-medium'>Available Time Slots</h3>
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
<div className='space-y-3'>
|
||||||
{bookingSlots.map((slot, index) => (
|
{/* 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' />
|
||||||
|
{time} -{' '}
|
||||||
|
{String(parseInt(time.split(':')[0]) + 1).padStart(2, '0')}:00
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`grid gap-3 ${
|
||||||
|
slotsForTime.length === 1
|
||||||
|
? 'grid-cols-1'
|
||||||
|
: 'grid-cols-1 sm:grid-cols-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{slotsForTime.map((slot) => (
|
||||||
<div
|
<div
|
||||||
key={`${slot.courtId}-${slot.time}`}
|
key={`${slot.courtId}-${slot.time}`}
|
||||||
className={`p-4 border rounded-lg transition-colors cursor-pointer ${
|
className={`p-3 border rounded-lg transition-colors cursor-pointer ${
|
||||||
slot.available
|
slot.available
|
||||||
? 'border-green-200 bg-green-50 hover:bg-green-100'
|
? 'border-green-200 bg-green-50 hover:bg-green-100'
|
||||||
: 'border-red-200 bg-red-50 cursor-not-allowed'
|
: 'border-red-200 bg-red-50 cursor-not-allowed'
|
||||||
@@ -510,31 +573,39 @@ export function EnhancedBookingCalendar() {
|
|||||||
onClick={() => handleSlotClick(slot)}
|
onClick={() => handleSlotClick(slot)}
|
||||||
>
|
>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='space-y-2'>
|
<div className='space-y-1 flex-1'>
|
||||||
<div className='flex items-center gap-2 text-sm font-medium'>
|
<div className='flex items-center gap-2 text-sm font-medium'>
|
||||||
<Clock className='h-4 w-4' />
|
|
||||||
{slot.time} -{' '}
|
|
||||||
{String(parseInt(slot.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' />
|
<MapPin className='h-4 w-4' />
|
||||||
{slot.courtName}
|
{slot.courtName}
|
||||||
</div>
|
</div>
|
||||||
{!slot.available && slot.bookedBy && (
|
{!slot.available && slot.bookedBy && (
|
||||||
|
<div className='space-y-1'>
|
||||||
<div className='flex items-center gap-2 text-xs text-red-600'>
|
<div className='flex items-center gap-2 text-xs text-red-600'>
|
||||||
<Users className='h-3 w-3' />
|
<Users className='h-3 w-3' />
|
||||||
Booked by {slot.bookedBy}
|
Booked by {slot.bookedBy}
|
||||||
</div>
|
</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 && (
|
{!slot.available && !slot.bookedBy && (
|
||||||
<div className='text-xs text-red-600'>Already booked</div>
|
<div className='text-xs text-red-600'>
|
||||||
|
Already booked
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
disabled={!slot.available}
|
disabled={!slot.available}
|
||||||
className={slot.available ? 'bg-green-600 hover:bg-green-700' : ''}
|
className={
|
||||||
|
slot.available
|
||||||
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{slot.available ? 'Book' : 'Booked'}
|
{slot.available ? 'Book' : 'Booked'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -543,6 +614,10 @@ export function EnhancedBookingCalendar() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No Slots Message */}
|
{/* No Slots Message */}
|
||||||
@@ -554,8 +629,8 @@ export function EnhancedBookingCalendar() {
|
|||||||
No courts available on {getDayName(selectedDate.getDay())}s
|
No courts available on {getDayName(selectedDate.getDay())}s
|
||||||
</div>
|
</div>
|
||||||
<p className='text-gray-500 text-sm'>
|
<p className='text-gray-500 text-sm'>
|
||||||
This facility is closed on {getDayName(selectedDate.getDay())}s.
|
This facility is closed on {getDayName(selectedDate.getDay())}s. Please
|
||||||
Please select a different day to make a booking.
|
select a different day to make a booking.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -268,8 +268,11 @@ export function UserBookingManagement() {
|
|||||||
<MapPin className='h-4 w-4 text-blue-600' />
|
<MapPin className='h-4 w-4 text-blue-600' />
|
||||||
<span className='font-medium text-sm'>{booking.court.name}</span>
|
<span className='font-medium text-sm'>{booking.court.name}</span>
|
||||||
{isToday(booking.date) && (
|
{isToday(booking.date) && (
|
||||||
<Badge variant='secondary' className='text-xs'>
|
<Badge
|
||||||
Today
|
variant='secondary'
|
||||||
|
className='text-xs bg-gradient-to-r from-orange-100 to-orange-200 text-orange-700 border-orange-300'
|
||||||
|
>
|
||||||
|
🎯 Today
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface DashboardHeaderProps {
|
|||||||
userId: string;
|
userId: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'user' | 'admin';
|
role: 'user' | 'admin';
|
||||||
|
name?: string;
|
||||||
|
surname?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +103,9 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
|
|||||||
className='flex items-center space-x-2'
|
className='flex items-center space-x-2'
|
||||||
>
|
>
|
||||||
<User className='h-4 w-4 text-gray-600' />
|
<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>
|
||||||
|
|
||||||
<Button variant='outline' size='sm' onClick={handleLogout} disabled={isLoggingOut}>
|
<Button variant='outline' size='sm' onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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).
|
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:
|
#### Example Configuration:
|
||||||
|
|
||||||
- **Sunday**: 12:00 - 17:00 (Weekend afternoon sessions)
|
- **Sunday**: 12:00 - 17:00 (Weekend afternoon sessions)
|
||||||
- **Monday**: 19:00 - 23:00 (Evening sessions only)
|
- **Monday**: 19:00 - 23:00 (Evening sessions only)
|
||||||
- **Tuesday**: 19:00 - 23:00 (Evening sessions only)
|
- **Tuesday**: 19:00 - 23:00 (Evening sessions only)
|
||||||
@@ -42,10 +43,12 @@ CREATE TABLE time_slots (
|
|||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
#### Public Endpoints (for authenticated users):
|
#### Public Endpoints (for authenticated users):
|
||||||
|
|
||||||
- `GET /api/time-slots` - Retrieve active time slots for all days
|
- `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/bookings/all?date=YYYY-MM-DD` - Get all bookings with user and court information
|
||||||
|
|
||||||
#### Admin Endpoints:
|
#### Admin Endpoints:
|
||||||
|
|
||||||
- `GET /api/admin/time-slots` - Retrieve all time slots (including inactive)
|
- `GET /api/admin/time-slots` - Retrieve all time slots (including inactive)
|
||||||
- `POST /api/admin/time-slots` - Create new time slot
|
- `POST /api/admin/time-slots` - Create new time slot
|
||||||
- `PUT /api/admin/time-slots/[id]` - Update existing time slot
|
- `PUT /api/admin/time-slots/[id]` - Update existing time slot
|
||||||
@@ -63,12 +66,14 @@ Administrators can manage time slots through the admin dashboard under the "Sett
|
|||||||
### User Experience
|
### User Experience
|
||||||
|
|
||||||
#### Enhanced Booking Calendar:
|
#### Enhanced Booking Calendar:
|
||||||
|
|
||||||
- Automatically adapts to show only available time slots for the selected day
|
- Automatically adapts to show only available time slots for the selected day
|
||||||
- Displays who has booked each unavailable slot
|
- Displays who has booked each unavailable slot
|
||||||
- Maintains mobile-responsive design
|
- Maintains mobile-responsive design
|
||||||
- Provides fallback to global settings if no day-specific slots are configured
|
- Provides fallback to global settings if no day-specific slots are configured
|
||||||
|
|
||||||
#### Booking Display:
|
#### Booking Display:
|
||||||
|
|
||||||
- Available slots: Green background with "Book" button
|
- Available slots: Green background with "Book" button
|
||||||
- Booked slots: Red background showing "Booked by [Full Name]"
|
- Booked slots: Red background showing "Booked by [Full Name]"
|
||||||
- Clear visual distinction between available and booked slots
|
- Clear visual distinction between available and booked slots
|
||||||
@@ -106,6 +111,7 @@ The system includes database seeding scripts that populate initial time slots ba
|
|||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
Potential future improvements could include:
|
Potential future improvements could include:
|
||||||
|
|
||||||
- Seasonal time slot variations
|
- Seasonal time slot variations
|
||||||
- Holiday-specific scheduling
|
- Holiday-specific scheduling
|
||||||
- Automatic time slot generation tools
|
- Automatic time slot generation tools
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# ✅ ROBUST BOOKING VALIDATION IMPLEMENTATION COMPLETE
|
# ✅ ROBUST BOOKING VALIDATION IMPLEMENTATION COMPLETE
|
||||||
|
|
||||||
## 🎯 **PROBLEM SOLVED**
|
## 🎯 **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"
|
**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**
|
## 🛡️ **COMPREHENSIVE VALIDATION LAYERS**
|
||||||
|
|
||||||
### **1. Database Layer ✅**
|
### **1. Database Layer ✅**
|
||||||
|
|
||||||
- **Time Slots Configuration**: Proper day-specific time slots in database
|
- **Time Slots Configuration**: Proper day-specific time slots in database
|
||||||
- **Current Configuration**:
|
- **Current Configuration**:
|
||||||
- Sunday: 12:00-17:00
|
- Sunday: 12:00-17:00
|
||||||
@@ -17,12 +19,15 @@
|
|||||||
- Saturday: 10:00-18:00
|
- Saturday: 10:00-18:00
|
||||||
|
|
||||||
### **2. API Layer Validation ✅**
|
### **2. API Layer Validation ✅**
|
||||||
|
|
||||||
**File**: `/app/api/bookings/route.ts`
|
**File**: `/app/api/bookings/route.ts`
|
||||||
|
|
||||||
- ✅ **Day Validation**: Rejects bookings on days with no time slots
|
- ✅ **Day Validation**: Rejects bookings on days with no time slots
|
||||||
- ✅ **Time Validation**: Rejects bookings outside allowed time ranges
|
- ✅ **Time Validation**: Rejects bookings outside allowed time ranges
|
||||||
- ✅ **Detailed Error Messages**: Specific feedback for different validation failures
|
- ✅ **Detailed Error Messages**: Specific feedback for different validation failures
|
||||||
|
|
||||||
**Example API Responses**:
|
**Example API Responses**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// Booking on Wednesday (closed day)
|
// Booking on Wednesday (closed day)
|
||||||
{
|
{
|
||||||
@@ -36,24 +41,29 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### **3. UI Layer Validation ✅**
|
### **3. UI Layer Validation ✅**
|
||||||
|
|
||||||
**File**: `/components/booking/enhanced-booking-calendar.tsx`
|
**File**: `/components/booking/enhanced-booking-calendar.tsx`
|
||||||
|
|
||||||
#### **Date Selection Prevention**:
|
#### **Date Selection Prevention**:
|
||||||
|
|
||||||
- ✅ `isDateSelectable()` function prevents selecting unavailable days
|
- ✅ `isDateSelectable()` function prevents selecting unavailable days
|
||||||
- ✅ Calendar disables days with no time slots
|
- ✅ Calendar disables days with no time slots
|
||||||
- ✅ Users cannot click on closed days
|
- ✅ Users cannot click on closed days
|
||||||
|
|
||||||
#### **Time Slot Generation**:
|
#### **Time Slot Generation**:
|
||||||
|
|
||||||
- ✅ `generateTimeSlots()` only shows available times for selected day
|
- ✅ `generateTimeSlots()` only shows available times for selected day
|
||||||
- ✅ **NO FALLBACK** to global settings - returns empty array if no day-specific slots
|
- ✅ **NO FALLBACK** to global settings - returns empty array if no day-specific slots
|
||||||
- ✅ `isDayBookable()` function checks if day has any active time slots
|
- ✅ `isDayBookable()` function checks if day has any active time slots
|
||||||
|
|
||||||
#### **Visual Feedback**:
|
#### **Visual Feedback**:
|
||||||
|
|
||||||
- ✅ Clear messages: "No courts available on Wednesdays"
|
- ✅ Clear messages: "No courts available on Wednesdays"
|
||||||
- ✅ Explains facility is closed on that day
|
- ✅ Explains facility is closed on that day
|
||||||
- ✅ Shows who booked each unavailable slot
|
- ✅ Shows who booked each unavailable slot
|
||||||
|
|
||||||
#### **Multiple Validation Points**:
|
#### **Multiple Validation Points**:
|
||||||
|
|
||||||
- ✅ `handleSlotClick()` - Prevents booking dialog on invalid slots
|
- ✅ `handleSlotClick()` - Prevents booking dialog on invalid slots
|
||||||
- ✅ `handleBookingConfirm()` - Final validation before API call
|
- ✅ `handleBookingConfirm()` - Final validation before API call
|
||||||
- ✅ Toast notifications for validation failures
|
- ✅ Toast notifications for validation failures
|
||||||
@@ -61,11 +71,13 @@
|
|||||||
### **4. User Experience Features ✅**
|
### **4. User Experience Features ✅**
|
||||||
|
|
||||||
#### **Day-Specific Booking Times**:
|
#### **Day-Specific Booking Times**:
|
||||||
|
|
||||||
- ✅ Different hours for different days of the week
|
- ✅ Different hours for different days of the week
|
||||||
- ✅ Admin can configure via Time Slot Management interface
|
- ✅ Admin can configure via Time Slot Management interface
|
||||||
- ✅ Automatic calendar adaptation based on selected date
|
- ✅ Automatic calendar adaptation based on selected date
|
||||||
|
|
||||||
#### **Enhanced Booking Display**:
|
#### **Enhanced Booking Display**:
|
||||||
|
|
||||||
- ✅ Shows "Booked by [Full Name]" instead of just "Booked"
|
- ✅ Shows "Booked by [Full Name]" instead of just "Booked"
|
||||||
- ✅ `/api/bookings/all` endpoint includes user information
|
- ✅ `/api/bookings/all` endpoint includes user information
|
||||||
- ✅ Clear visual distinction between available/unavailable slots
|
- ✅ Clear visual distinction between available/unavailable slots
|
||||||
@@ -75,14 +87,17 @@
|
|||||||
The system now prevents ALL of these invalid booking attempts:
|
The system now prevents ALL of these invalid booking attempts:
|
||||||
|
|
||||||
1. **❌ Booking on Closed Days**
|
1. **❌ Booking on Closed Days**
|
||||||
|
|
||||||
- UI: Date not selectable, clear "facility closed" message
|
- UI: Date not selectable, clear "facility closed" message
|
||||||
- API: "No bookings are allowed on Wednesdays"
|
- API: "No bookings are allowed on Wednesdays"
|
||||||
|
|
||||||
2. **❌ Booking at Wrong Times**
|
2. **❌ Booking at Wrong Times**
|
||||||
|
|
||||||
- UI: Time slot not generated, not displayed
|
- UI: Time slot not generated, not displayed
|
||||||
- API: "Time slot 10:00 is not available on Mondays"
|
- API: "Time slot 10:00 is not available on Mondays"
|
||||||
|
|
||||||
3. **❌ Direct API Attacks**
|
3. **❌ Direct API Attacks**
|
||||||
|
|
||||||
- Comprehensive server-side validation
|
- Comprehensive server-side validation
|
||||||
- Detailed error messages for debugging
|
- Detailed error messages for debugging
|
||||||
- No way to bypass UI restrictions
|
- No way to bypass UI restrictions
|
||||||
@@ -95,6 +110,7 @@ The system now prevents ALL of these invalid booking attempts:
|
|||||||
## 🎯 **SECURITY GUARANTEES**
|
## 🎯 **SECURITY GUARANTEES**
|
||||||
|
|
||||||
### **Zero Bypass Paths**:
|
### **Zero Bypass Paths**:
|
||||||
|
|
||||||
- ✅ Users cannot select unavailable dates in calendar
|
- ✅ Users cannot select unavailable dates in calendar
|
||||||
- ✅ Users cannot see unavailable time slots
|
- ✅ Users cannot see unavailable time slots
|
||||||
- ✅ Users cannot click on invalid slots
|
- ✅ Users cannot click on invalid slots
|
||||||
@@ -102,6 +118,7 @@ The system now prevents ALL of these invalid booking attempts:
|
|||||||
- ✅ API rejects all invalid booking attempts with specific errors
|
- ✅ API rejects all invalid booking attempts with specific errors
|
||||||
|
|
||||||
### **Admin Control**:
|
### **Admin Control**:
|
||||||
|
|
||||||
- ✅ Complete control over which days have courts available
|
- ✅ Complete control over which days have courts available
|
||||||
- ✅ Flexible time ranges per day
|
- ✅ Flexible time ranges per day
|
||||||
- ✅ Easy enable/disable of specific time slots
|
- ✅ Easy enable/disable of specific time slots
|
||||||
@@ -110,6 +127,7 @@ The system now prevents ALL of these invalid booking attempts:
|
|||||||
## 📋 **IMPLEMENTATION FILES**
|
## 📋 **IMPLEMENTATION FILES**
|
||||||
|
|
||||||
### **Modified/Created Files**:
|
### **Modified/Created Files**:
|
||||||
|
|
||||||
1. ✅ `/app/api/bookings/route.ts` - Server-side validation
|
1. ✅ `/app/api/bookings/route.ts` - Server-side validation
|
||||||
2. ✅ `/components/booking/enhanced-booking-calendar.tsx` - UI validation
|
2. ✅ `/components/booking/enhanced-booking-calendar.tsx` - UI validation
|
||||||
3. ✅ `/app/api/time-slots/route.ts` - Public time slots API
|
3. ✅ `/app/api/time-slots/route.ts` - Public time slots API
|
||||||
@@ -119,6 +137,7 @@ The system now prevents ALL of these invalid booking attempts:
|
|||||||
7. ✅ Database schema with proper time_slots table
|
7. ✅ Database schema with proper time_slots table
|
||||||
|
|
||||||
### **Validation Functions**:
|
### **Validation Functions**:
|
||||||
|
|
||||||
- ✅ `isDayBookable()` - Checks if day has any time slots
|
- ✅ `isDayBookable()` - Checks if day has any time slots
|
||||||
- ✅ `isDateSelectable()` - Prevents selecting unavailable dates
|
- ✅ `isDateSelectable()` - Prevents selecting unavailable dates
|
||||||
- ✅ `generateTimeSlots()` - Only returns valid times for day
|
- ✅ `generateTimeSlots()` - Only returns valid times for day
|
||||||
@@ -127,6 +146,7 @@ The system now prevents ALL of these invalid booking attempts:
|
|||||||
## 🚀 **RESULT**
|
## 🚀 **RESULT**
|
||||||
|
|
||||||
**PROBLEM COMPLETELY SOLVED**:
|
**PROBLEM COMPLETELY SOLVED**:
|
||||||
|
|
||||||
- ❌ Users can NO LONGER book on days without time slots
|
- ❌ Users can NO LONGER book on days without time slots
|
||||||
- ❌ Users can NO LONGER book at unavailable times
|
- ❌ Users can NO LONGER book at unavailable times
|
||||||
- ❌ No fallback to global settings - strict day-specific enforcement
|
- ❌ No fallback to global settings - strict day-specific enforcement
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1758575829475,
|
"when": 1758575829475,
|
||||||
"tag": "0000_tidy_kang",
|
"tag": "0000_tidy_kang",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1758824962110,
|
||||||
|
"tag": "0001_slimy_starjammers",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,16 @@ export const activityLogs = sqliteTable('activity_logs', {
|
|||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
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
|
// Zod schemas for validation
|
||||||
export const insertUserSchema = createInsertSchema(users);
|
export const insertUserSchema = createInsertSchema(users);
|
||||||
export const selectUserSchema = createSelectSchema(users);
|
export const selectUserSchema = createSelectSchema(users);
|
||||||
@@ -106,6 +116,8 @@ export const insertSettingSchema = createInsertSchema(settings);
|
|||||||
export const selectSettingSchema = createSelectSchema(settings);
|
export const selectSettingSchema = createSelectSchema(settings);
|
||||||
export const insertActivityLogSchema = createInsertSchema(activityLogs);
|
export const insertActivityLogSchema = createInsertSchema(activityLogs);
|
||||||
export const selectActivityLogSchema = createSelectSchema(activityLogs);
|
export const selectActivityLogSchema = createSelectSchema(activityLogs);
|
||||||
|
export const insertMetricSchema = createInsertSchema(metrics);
|
||||||
|
export const selectMetricSchema = createSelectSchema(metrics);
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
@@ -122,3 +134,5 @@ export type Setting = typeof settings.$inferSelect;
|
|||||||
export type NewSetting = typeof settings.$inferInsert;
|
export type NewSetting = typeof settings.$inferInsert;
|
||||||
export type ActivityLog = typeof activityLogs.$inferSelect;
|
export type ActivityLog = typeof activityLogs.$inferSelect;
|
||||||
export type NewActivityLog = typeof activityLogs.$inferInsert;
|
export type NewActivityLog = typeof activityLogs.$inferInsert;
|
||||||
|
export type Metric = typeof metrics.$inferSelect;
|
||||||
|
export type NewMetric = typeof metrics.$inferInsert;
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user