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

This commit is contained in:
mikicvi
2025-09-25 20:23:18 +01:00
parent 6d3202e385
commit b89d91ade2
20 changed files with 1358 additions and 328 deletions
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { db } from '@/lib/db';
import { users, courts, bookings, metrics } from '@/lib/db/schema';
import { eq, and, gte, lte, desc, count } from 'drizzle-orm';
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get real stats from database
const totalUsers = await db.select({ count: count() }).from(users);
const activeCourts = await db.select({ count: count() }).from(courts).where(eq(courts.isActive, true));
// Get today's bookings count
const today = new Date().toISOString().split('T')[0];
const todaysBookings = await db
.select({ count: count() })
.from(bookings)
.where(and(eq(bookings.date, today), eq(bookings.status, 'active')));
// Get current month's bookings from metrics table
const currentMonth = new Date().toISOString().substring(0, 7); // "2025-09"
const monthlyBookings = await db
.select()
.from(metrics)
.where(and(eq(metrics.metricType, 'monthly_bookings'), eq(metrics.period, currentMonth)))
.limit(1);
// Get recent bookings with user names
const recentBookings = await db
.select({
id: bookings.id,
date: bookings.date,
startTime: bookings.startTime,
endTime: bookings.endTime,
courtName: courts.name,
userName: users.name,
userSurname: users.surname,
status: bookings.status,
createdAt: bookings.createdAt,
})
.from(bookings)
.leftJoin(users, eq(bookings.userId, users.id))
.leftJoin(courts, eq(bookings.courtId, courts.id))
.orderBy(desc(bookings.createdAt))
.limit(10);
return NextResponse.json({
stats: {
totalUsers: totalUsers[0]?.count || 0,
activeCourts: activeCourts[0]?.count || 0,
todaysBookings: todaysBookings[0]?.count || 0,
monthlyBookings: monthlyBookings[0]?.value || 0,
},
recentBookings: recentBookings.map((booking) => ({
...booking,
userName: `${booking.userName} ${booking.userSurname}`,
})),
});
} catch (error) {
console.error('Error fetching admin stats:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+2 -8
View File
@@ -31,17 +31,11 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/; 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
+2 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+111 -72
View File
@@ -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
slot.isActive className={`px-2 py-1 rounded-full text-xs ${
? 'bg-green-100 text-green-800' slot.isActive
: 'bg-gray-100 text-gray-800' ? 'bg-green-100 text-green-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>
))} ))}
+25 -6
View File
@@ -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}>
{loading ? 'Creating...' : 'Create User'}
</Button>
</div> </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>
+63 -25
View File
@@ -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>
+152 -77
View File
@@ -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,34 +457,55 @@ 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) => {
<Button const isSelectedDate = date.toDateString() === selectedDate.toDateString();
key={index} const isTodayDate = isToday(date);
variant={
date.toDateString() === selectedDate.toDateString() return (
? 'default' <Button
: 'outline' key={index}
} variant={isSelectedDate ? '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
<span className='text-xs font-normal'> ? 'ring-2 ring-blue-400 ring-opacity-50 bg-blue-50 border-blue-200 hover:bg-blue-100'
{date.toLocaleDateString('en-US', { weekday: 'short' })} : ''
</span> } ${
<span className='font-semibold'>{date.getDate()}</span> isSelectedDate && isTodayDate
<span className='text-xs font-normal'> ? 'bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800'
{date.toLocaleDateString('en-US', { month: 'short' })} : ''
</span> }`}
{isToday(date) && <span className='text-xs text-blue-600'>Today</span>} >
</Button> {isTodayDate && (
))} <div className='absolute -top-1 -right-1 w-3 h-3 bg-orange-500 rounded-full animate-pulse' />
)}
<span className='text-xs font-normal'>
{date.toLocaleDateString('en-US', { weekday: 'short' })}
</span>
<span className='font-semibold'>{date.getDate()}</span>
<span className='text-xs font-normal'>
{date.toLocaleDateString('en-US', { month: 'short' })}
</span>
</Button>
);
})}
</div> </div>
</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,53 +537,85 @@ 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 */}
<div {Array.from(new Set(bookingSlots.map((slot) => slot.time)))
key={`${slot.courtId}-${slot.time}`} .sort()
className={`p-4 border rounded-lg transition-colors cursor-pointer ${ .map((time) => {
slot.available const slotsForTime = bookingSlots.filter((slot) => slot.time === time);
? 'border-green-200 bg-green-50 hover:bg-green-100'
: 'border-red-200 bg-red-50 cursor-not-allowed' return (
}`} <div key={time} className='space-y-2'>
onClick={() => handleSlotClick(slot)} <div className='flex items-center gap-2 text-sm font-medium text-gray-700'>
>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<div className='flex items-center gap-2 text-sm font-medium'>
<Clock className='h-4 w-4' /> <Clock className='h-4 w-4' />
{slot.time} -{' '} {time} -{' '}
{String(parseInt(slot.time.split(':')[0]) + 1).padStart(2, '0')} {String(parseInt(time.split(':')[0]) + 1).padStart(2, '0')}:00
:00
</div> </div>
<div className='flex items-center gap-2 text-sm text-gray-600'> <div
<MapPin className='h-4 w-4' /> className={`grid gap-3 ${
{slot.courtName} slotsForTime.length === 1
? 'grid-cols-1'
: 'grid-cols-1 sm:grid-cols-2'
}`}
>
{slotsForTime.map((slot) => (
<div
key={`${slot.courtId}-${slot.time}`}
className={`p-3 border rounded-lg transition-colors cursor-pointer ${
slot.available
? 'border-green-200 bg-green-50 hover:bg-green-100'
: 'border-red-200 bg-red-50 cursor-not-allowed'
}`}
onClick={() => handleSlotClick(slot)}
>
<div className='flex items-center justify-between'>
<div className='space-y-1 flex-1'>
<div className='flex items-center gap-2 text-sm font-medium'>
<MapPin className='h-4 w-4' />
{slot.courtName}
</div>
{!slot.available && slot.bookedBy && (
<div className='space-y-1'>
<div className='flex items-center gap-2 text-xs text-red-600'>
<Users className='h-3 w-3' />
Booked by {slot.bookedBy}
</div>
{slot.partner && (
<div className='flex items-center gap-2 text-xs text-orange-600'>
<User className='h-3 w-3' />
Playing with: {slot.partner}
</div>
)}
</div>
)}
{!slot.available && !slot.bookedBy && (
<div className='text-xs text-red-600'>
Already booked
</div>
)}
</div>
<Button
size='sm'
disabled={!slot.available}
className={
slot.available
? 'bg-green-600 hover:bg-green-700'
: ''
}
>
{slot.available ? 'Book' : 'Booked'}
</Button>
</div>
</div>
))}
</div> </div>
{!slot.available && slot.bookedBy && (
<div className='flex items-center gap-2 text-xs text-red-600'>
<Users className='h-3 w-3' />
Booked by {slot.bookedBy}
</div>
)}
{!slot.available && !slot.bookedBy && (
<div className='text-xs text-red-600'>Already booked</div>
)}
</div> </div>
<Button );
size='sm' })}
disabled={!slot.available}
className={slot.available ? 'bg-green-600 hover:bg-green-700' : ''}
>
{slot.available ? 'Book' : 'Booked'}
</Button>
</div>
</div>
))}
</div> </div>
</div> </div>
)} )}
@@ -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>
+5 -1
View File
@@ -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}>
+44 -38
View File
@@ -9,13 +9,14 @@ This update introduces two major features to the table tennis booking system:
The system now supports different booking hours for different days of the week. Administrators can configure custom time slots for each day (Sunday through Saturday). 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)
- **Monday**: 19:00 - 23:00 (Evening sessions only) - **Sunday**: 12:00 - 17:00 (Weekend afternoon sessions)
- **Tuesday**: 19:00 - 23:00 (Evening sessions only) - **Monday**: 19:00 - 23:00 (Evening sessions only)
- **Wednesday**: 18:00 - 22:00 (Shorter evening sessions) - **Tuesday**: 19:00 - 23:00 (Evening sessions only)
- **Thursday**: 19:00 - 23:00 (Evening sessions only) - **Wednesday**: 18:00 - 22:00 (Shorter evening sessions)
- **Friday**: 18:00 - 22:00 (Shorter evening sessions) - **Thursday**: 19:00 - 23:00 (Evening sessions only)
- **Saturday**: 10:00 - 18:00 (Full day weekend sessions) - **Friday**: 18:00 - 22:00 (Shorter evening sessions)
- **Saturday**: 10:00 - 18:00 (Full day weekend sessions)
### 2. User Name Display ### 2. User Name Display
@@ -42,36 +43,40 @@ 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/bookings/all?date=YYYY-MM-DD` - Get all bookings with user and court information - `GET /api/time-slots` - Retrieve active time slots for all days
- `GET /api/bookings/all?date=YYYY-MM-DD` - Get all bookings with user and court information
#### Admin Endpoints: #### Admin Endpoints:
- `GET /api/admin/time-slots` - Retrieve all time slots (including inactive)
- `POST /api/admin/time-slots` - Create new time slot - `GET /api/admin/time-slots` - Retrieve all time slots (including inactive)
- `PUT /api/admin/time-slots/[id]` - Update existing time slot - `POST /api/admin/time-slots` - Create new time slot
- `DELETE /api/admin/time-slots/[id]` - Delete time slot - `PUT /api/admin/time-slots/[id]` - Update existing time slot
- `DELETE /api/admin/time-slots/[id]` - Delete time slot
### Admin Management ### Admin Management
Administrators can manage time slots through the admin dashboard under the "Settings" tab. The interface allows: Administrators can manage time slots through the admin dashboard under the "Settings" tab. The interface allows:
- **Create Time Slots**: Set day of week, start time, end time, and active status - **Create Time Slots**: Set day of week, start time, end time, and active status
- **Edit Time Slots**: Modify existing time slot configurations - **Edit Time Slots**: Modify existing time slot configurations
- **Delete Time Slots**: Remove time slot configurations - **Delete Time Slots**: Remove time slot configurations
- **Activate/Deactivate**: Toggle time slots on/off without deletion - **Activate/Deactivate**: Toggle time slots on/off without deletion
### User Experience ### User Experience
#### Enhanced Booking Calendar: #### Enhanced Booking Calendar:
- Automatically adapts to show only available time slots for the selected day
- Displays who has booked each unavailable slot - Automatically adapts to show only available time slots for the selected day
- Maintains mobile-responsive design - Displays who has booked each unavailable slot
- Provides fallback to global settings if no day-specific slots are configured - Maintains mobile-responsive design
- Provides fallback to global settings if no day-specific slots are configured
#### Booking Display: #### Booking Display:
- Available slots: Green background with "Book" button
- Booked slots: Red background showing "Booked by [Full Name]" - Available slots: Green background with "Book" button
- Clear visual distinction between available and booked slots - Booked slots: Red background showing "Booked by [Full Name]"
- Clear visual distinction between available and booked slots
## Usage Instructions ## Usage Instructions
@@ -79,10 +84,10 @@ Administrators can manage time slots through the admin dashboard under the "Sett
1. **Navigate to Admin Dashboard** → Settings tab 1. **Navigate to Admin Dashboard** → Settings tab
2. **Time Slot Management section** allows you to: 2. **Time Slot Management section** allows you to:
- Add new time slots for specific days - Add new time slots for specific days
- Edit existing time slot configurations - Edit existing time slot configurations
- View all time slots organized by day of the week - View all time slots organized by day of the week
- Activate/deactivate time slots as needed - Activate/deactivate time slots as needed
### For Users: ### For Users:
@@ -93,11 +98,11 @@ Administrators can manage time slots through the admin dashboard under the "Sett
## Technical Benefits ## Technical Benefits
- **Flexible Scheduling**: Different operational hours for different days - **Flexible Scheduling**: Different operational hours for different days
- **User Transparency**: Know who's playing when for coordination - **User Transparency**: Know who's playing when for coordination
- **Administrative Control**: Easy management of time slots - **Administrative Control**: Easy management of time slots
- **Backward Compatibility**: Maintains fallback to global settings - **Backward Compatibility**: Maintains fallback to global settings
- **Mobile Optimized**: Responsive design across all devices - **Mobile Optimized**: Responsive design across all devices
## Migration ## Migration
@@ -106,8 +111,9 @@ 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
- Holiday-specific scheduling - Seasonal time slot variations
- Automatic time slot generation tools - Holiday-specific scheduling
- Bulk time slot operations - Automatic time slot generation tools
- Time slot templates for quick setup - Bulk time slot operations
- Time slot templates for quick setup
+79 -59
View File
@@ -1,28 +1,33 @@
# ✅ 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
- **Current Configuration**: - **Time Slots Configuration**: Proper day-specific time slots in database
- Sunday: 12:00-17:00 - **Current Configuration**:
- Monday: 19:00-23:00 - Sunday: 12:00-17:00
- Tuesday: 19:00-23:00 - Monday: 19:00-23:00
- **Wednesday: CLOSED** (no time slots) - Tuesday: 19:00-23:00
- **Thursday: CLOSED** (no time slots) - **Wednesday: CLOSED** (no time slots)
- Friday: 18:00-22:00 - **Thursday: CLOSED** (no time slots)
- Saturday: 10:00-18:00 - Friday: 18:00-22: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
-**Time Validation**: Rejects bookings outside allowed time ranges - **Day Validation**: Rejects bookings on days with no time slots
-**Detailed Error Messages**: Specific feedback for different validation failures - **Time Validation**: Rejects bookings outside allowed time ranges
-**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,80 +41,93 @@
``` ```
### **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
- ✅ Calendar disables days with no time slots - `isDateSelectable()` function prevents selecting unavailable days
- ✅ Users cannot click on closed days - ✅ Calendar disables days with no time slots
- ✅ Users cannot click on closed days
#### **Time Slot Generation**: #### **Time Slot Generation**:
-`generateTimeSlots()` only shows available times for selected day
- **NO FALLBACK** to global settings - returns empty array if no day-specific slots - `generateTimeSlots()` only shows available times for selected day
- `isDayBookable()` function checks if day has any active time slots - **NO FALLBACK** to global settings - returns empty array if no day-specific slots
-`isDayBookable()` function checks if day has any active time slots
#### **Visual Feedback**: #### **Visual Feedback**:
- ✅ Clear messages: "No courts available on Wednesdays"
- ✅ Explains facility is closed on that day - ✅ Clear messages: "No courts available on Wednesdays"
- ✅ Shows who booked each unavailable slot - ✅ Explains facility is closed on that day
- ✅ Shows who booked each unavailable slot
#### **Multiple Validation Points**: #### **Multiple Validation Points**:
-`handleSlotClick()` - Prevents booking dialog on invalid slots
-`handleBookingConfirm()` - Final validation before API call - `handleSlotClick()` - Prevents booking dialog on invalid slots
- ✅ Toast notifications for validation failures - `handleBookingConfirm()` - Final validation before API call
- ✅ Toast notifications for validation failures
### **4. User Experience Features ✅** ### **4. User Experience Features ✅**
#### **Day-Specific Booking Times**: #### **Day-Specific Booking Times**:
- ✅ Different hours for different days of the week
- ✅ Admin can configure via Time Slot Management interface - ✅ Different hours for different days of the week
- ✅ Automatic calendar adaptation based on selected date - ✅ Admin can configure via Time Slot Management interface
- ✅ Automatic calendar adaptation based on selected date
#### **Enhanced Booking Display**: #### **Enhanced Booking Display**:
- ✅ Shows "Booked by [Full Name]" instead of just "Booked"
- `/api/bookings/all` endpoint includes user information - ✅ Shows "Booked by [Full Name]" instead of just "Booked"
- ✅ Clear visual distinction between available/unavailable slots - `/api/bookings/all` endpoint includes user information
- ✅ Clear visual distinction between available/unavailable slots
## 🧪 **VALIDATION TEST SCENARIOS** ## 🧪 **VALIDATION TEST SCENARIOS**
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
- API: "No bookings are allowed on Wednesdays" - UI: Date not selectable, clear "facility closed" message
- API: "No bookings are allowed on Wednesdays"
2. **❌ Booking at Wrong Times** 2. **❌ Booking at Wrong Times**
- UI: Time slot not generated, not displayed
- API: "Time slot 10:00 is not available on Mondays" - UI: Time slot not generated, not displayed
- API: "Time slot 10:00 is not available on Mondays"
3. **❌ Direct API Attacks** 3. **❌ Direct API Attacks**
- Comprehensive server-side validation
- Detailed error messages for debugging - Comprehensive server-side validation
- No way to bypass UI restrictions - Detailed error messages for debugging
- No way to bypass UI restrictions
4. **✅ Valid Bookings Only** 4. **✅ Valid Bookings Only**
- Only shows available times for bookable days - Only shows available times for bookable days
- Only allows clicks on valid time slots - Only allows clicks on valid time slots
- Only processes API calls for valid day/time combinations - Only processes API calls for valid day/time combinations
## 🎯 **SECURITY GUARANTEES** ## 🎯 **SECURITY GUARANTEES**
### **Zero Bypass Paths**: ### **Zero Bypass Paths**:
- ✅ Users cannot select unavailable dates in calendar
- ✅ Users cannot see unavailable time slots - ✅ Users cannot select unavailable dates in calendar
- ✅ Users cannot click on invalid slots - ✅ Users cannot see unavailable time slots
- ✅ Users cannot submit booking forms for invalid times - ✅ Users cannot click on invalid slots
- ✅ API rejects all invalid booking attempts with specific errors - ✅ Users cannot submit booking forms for invalid times
- ✅ API rejects all invalid booking attempts with specific errors
### **Admin Control**: ### **Admin Control**:
- ✅ Complete control over which days have courts available
- ✅ Flexible time ranges per day - ✅ Complete control over which days have courts available
- ✅ Easy enable/disable of specific time slots - ✅ Flexible time ranges per day
- ✅ Activity logging of all time slot changes - ✅ Easy enable/disable of specific time slots
- ✅ Activity logging of all time slot changes
## 📋 **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,19 +137,21 @@ 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
-`isDateSelectable()` - Prevents selecting unavailable dates - `isDayBookable()` - Checks if day has any time slots
-`generateTimeSlots()` - Only returns valid times for day - `isDateSelectable()` - Prevents selecting unavailable dates
- ✅ Server-side day/time validation in booking API - `generateTimeSlots()` - Only returns valid times for day
- ✅ Server-side day/time validation in booking API
## 🚀 **RESULT** ## 🚀 **RESULT**
**PROBLEM COMPLETELY SOLVED**: **PROBLEM COMPLETELY SOLVED**:
- ❌ Users can NO LONGER book on days without time slots
- ❌ Users can NO LONGER book at unavailable times - ❌ Users can NO LONGER book on days without time slots
- ❌ No fallback to global settings - strict day-specific enforcement - ❌ Users can NO LONGER book at unavailable times
- ✅ Clear communication about facility availability - ❌ No fallback to global settings - strict day-specific enforcement
- ✅ Robust validation at every layer (UI, API, Database) - ✅ Clear communication about facility availability
- ✅ Enhanced UX with user names and day-specific times - ✅ Robust validation at every layer (UI, API, Database)
- ✅ Enhanced UX with user names and day-specific times
The system is now **bulletproof** against invalid booking attempts through any channel. The system is now **bulletproof** against invalid booking attempts through any channel.
@@ -0,0 +1,8 @@
CREATE TABLE `metrics` (
`id` text PRIMARY KEY NOT NULL,
`metric_type` text NOT NULL,
`period` text NOT NULL,
`value` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
+549
View File
@@ -0,0 +1,549 @@
{
"version": "5",
"dialect": "sqlite",
"id": "b6ff7034-4299-4b61-8a16-3b46eae7b4ef",
"prevId": "55393d37-4cdf-45ba-aa6a-1e50b082b57c",
"tables": {
"activity_logs": {
"name": "activity_logs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"activity_logs_user_id_users_id_fk": {
"name": "activity_logs_user_id_users_id_fk",
"tableFrom": "activity_logs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"announcements": {
"name": "announcements",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"bookings": {
"name": "bookings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"court_id": {
"name": "court_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"bookings_user_id_users_id_fk": {
"name": "bookings_user_id_users_id_fk",
"tableFrom": "bookings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"bookings_court_id_courts_id_fk": {
"name": "bookings_court_id_courts_id_fk",
"tableFrom": "bookings",
"tableTo": "courts",
"columnsFrom": [
"court_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"courts": {
"name": "courts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"metrics": {
"name": "metrics",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"metric_type": {
"name": "metric_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"period": {
"name": "period",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"settings_key_unique": {
"name": "settings_key_unique",
"columns": [
"key"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"time_slots": {
"name": "time_slots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"day_of_week": {
"name": "day_of_week",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"surname": {
"name": "surname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1758575829475, "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
} }
] ]
} }
+14
View File
@@ -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;
+78
View File
@@ -0,0 +1,78 @@
import { db } from '../lib/db';
import { settings, metrics } from '../lib/db/schema';
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
async function initializeAdminData() {
try {
console.log('Initializing admin dashboard data...');
const now = new Date();
// Initialize default settings
console.log('Setting up default settings...');
const defaultSettings = [
{
id: randomUUID(),
key: 'booking_window_days',
value: '14',
updatedAt: now,
},
{
id: randomUUID(),
key: 'max_booking_duration_hours',
value: '2',
updatedAt: now,
},
{
id: randomUUID(),
key: 'max_bookings_per_user_per_hour_per_day',
value: '1',
updatedAt: now,
},
];
// Insert settings if they don't exist
for (const setting of defaultSettings) {
const existingSetting = await db.select().from(settings).where(eq(settings.key, setting.key)).limit(1);
if (existingSetting.length === 0) {
await db.insert(settings).values(setting);
console.log(`✓ Setting '${setting.key}' initialized with value '${setting.value}'`);
} else {
console.log(`- Setting '${setting.key}' already exists`);
}
}
// Initialize current month's metrics
console.log('Initializing monthly metrics...');
const currentMonth = now.toISOString().substring(0, 7); // "2025-09"
const existingMetric = await db.select().from(metrics).where(eq(metrics.period, currentMonth)).limit(1);
if (existingMetric.length === 0) {
const monthlyMetric = {
id: randomUUID(),
metricType: 'monthly_bookings',
period: currentMonth,
value: 0,
createdAt: now,
updatedAt: now,
};
await db.insert(metrics).values(monthlyMetric);
console.log(`✓ Monthly metrics initialized for ${currentMonth}`);
} else {
console.log(`- Monthly metrics for ${currentMonth} already exist`);
}
console.log('Admin dashboard data initialization complete!');
} catch (error) {
console.error('Error initializing admin data:', error);
throw error;
}
}
initializeAdminData();