From b89d91ade289be73e0c92ccf02619ca09dd7166c Mon Sep 17 00:00:00 2001
From: mikicvi <88291034+mikicvi@users.noreply.github.com>
Date: Thu, 25 Sep 2025 20:23:18 +0100
Subject: [PATCH] additional features, refinement and more control over the app
from admin side, better bookings UX
---
app/api/admin/stats/route.ts | 68 +++
app/api/admin/time-slots/[id]/route.ts | 10 +-
app/api/admin/time-slots/route.ts | 10 +-
app/api/bookings/all/route.ts | 2 +-
app/api/bookings/route.ts | 89 ++-
app/dashboard/page.tsx | 32 +-
components/admin/AdminSettingsManagement.tsx | 21 +
components/admin/AdminTimeSlotManagement.tsx | 197 ++++---
components/admin/AdminUserManagement.tsx | 31 +-
components/admin/admin-dashboard.tsx | 88 ++-
.../booking/enhanced-booking-calendar.tsx | 239 +++++---
.../booking/user-booking-management.tsx | 7 +-
components/dashboard/dashboard-header.tsx | 6 +-
docs/DAY_SPECIFIC_FEATURES.md | 82 +--
docs/ROBUST_VALIDATION_COMPLETE.md | 148 +++--
lib/db/migrations/0001_slimy_starjammers.sql | 8 +
lib/db/migrations/meta/0001_snapshot.json | 549 ++++++++++++++++++
lib/db/migrations/meta/_journal.json | 7 +
lib/db/schema.ts | 14 +
scripts/init-admin-data.ts | 78 +++
20 files changed, 1358 insertions(+), 328 deletions(-)
create mode 100644 app/api/admin/stats/route.ts
create mode 100644 lib/db/migrations/0001_slimy_starjammers.sql
create mode 100644 lib/db/migrations/meta/0001_snapshot.json
create mode 100644 scripts/init-admin-data.ts
diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts
new file mode 100644
index 0000000..90f66df
--- /dev/null
+++ b/app/api/admin/stats/route.ts
@@ -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 });
+ }
+}
diff --git a/app/api/admin/time-slots/[id]/route.ts b/app/api/admin/time-slots/[id]/route.ts
index de39a3e..d4128bf 100644
--- a/app/api/admin/time-slots/[id]/route.ts
+++ b/app/api/admin/time-slots/[id]/route.ts
@@ -31,17 +31,11 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (startTime && !timeRegex.test(startTime)) {
- return NextResponse.json(
- { error: 'Invalid startTime format. Use HH:MM format' },
- { status: 400 }
- );
+ return NextResponse.json({ error: 'Invalid startTime format. Use HH:MM format' }, { status: 400 });
}
if (endTime && !timeRegex.test(endTime)) {
- return NextResponse.json(
- { error: 'Invalid endTime format. Use HH:MM format' },
- { status: 400 }
- );
+ return NextResponse.json({ error: 'Invalid endTime format. Use HH:MM format' }, { status: 400 });
}
const updatedTimeSlot = await db
diff --git a/app/api/admin/time-slots/route.ts b/app/api/admin/time-slots/route.ts
index 7b6f9ca..3863958 100644
--- a/app/api/admin/time-slots/route.ts
+++ b/app/api/admin/time-slots/route.ts
@@ -12,10 +12,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
- const allTimeSlots = await db
- .select()
- .from(timeSlots)
- .orderBy(timeSlots.dayOfWeek, timeSlots.startTime);
+ const allTimeSlots = await db.select().from(timeSlots).orderBy(timeSlots.dayOfWeek, timeSlots.startTime);
return NextResponse.json({
timeSlots: allTimeSlots,
@@ -53,10 +50,7 @@ export async function POST(request: NextRequest) {
// Validate time format (HH:MM)
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(startTime) || !timeRegex.test(endTime)) {
- return NextResponse.json(
- { error: 'Invalid time format. Use HH:MM format' },
- { status: 400 }
- );
+ return NextResponse.json({ error: 'Invalid time format. Use HH:MM format' }, { status: 400 });
}
const newTimeSlot = await db
diff --git a/app/api/bookings/all/route.ts b/app/api/bookings/all/route.ts
index d8e0f4c..878e307 100644
--- a/app/api/bookings/all/route.ts
+++ b/app/api/bookings/all/route.ts
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
// Build query conditions
const whereConditions = [];
whereConditions.push(eq(bookings.status, 'active'));
-
+
if (date) {
whereConditions.push(eq(bookings.date, date));
}
diff --git a/app/api/bookings/route.ts b/app/api/bookings/route.ts
index 725fb0f..bd30e75 100644
--- a/app/api/bookings/route.ts
+++ b/app/api/bookings/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
-import { bookings, courts, timeSlots } from '@/lib/db/schema';
+import { bookings, courts, timeSlots, settings, metrics } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { getSession } from '@/lib/session';
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
@@ -45,7 +45,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
- const { courtId, date, timeSlot } = await request.json();
+ const { courtId, date, timeSlot, notes } = await request.json();
if (!courtId || !date || !timeSlot) {
return NextResponse.json(
@@ -91,18 +91,15 @@ export async function POST(request: NextRequest) {
const availableTimeSlots = await db
.select()
.from(timeSlots)
- .where(
- and(
- eq(timeSlots.dayOfWeek, dayOfWeek),
- eq(timeSlots.isActive, true)
- )
- );
+ .where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
// Check if any time slots are configured for this day
if (availableTimeSlots.length === 0) {
return NextResponse.json(
{
- error: `No bookings are allowed on ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]}s. The facility is closed on this day.`,
+ error: `No bookings are allowed on ${
+ ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
+ }s. The facility is closed on this day.`,
},
{ status: 400 }
);
@@ -110,17 +107,50 @@ export async function POST(request: NextRequest) {
// Check if the requested time slot is within any of the allowed time ranges
const requestedHour = parseInt(startTime.split(':')[0]);
- const isTimeSlotValid = availableTimeSlots.some(slot => {
+ const isTimeSlotValid = availableTimeSlots.some((slot) => {
const slotStartHour = parseInt(slot.startTime.split(':')[0]);
const slotEndHour = parseInt(slot.endTime.split(':')[0]);
return requestedHour >= slotStartHour && requestedHour < slotEndHour;
});
if (!isTimeSlotValid) {
- const allowedRanges = availableTimeSlots.map(slot => `${slot.startTime}-${slot.endTime}`).join(', ');
+ const allowedRanges = availableTimeSlots.map((slot) => `${slot.startTime}-${slot.endTime}`).join(', ');
return NextResponse.json(
{
- error: `Time slot ${startTime} is not available on ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]}s. Available times: ${allowedRanges}`,
+ error: `Time slot ${startTime} is not available on ${
+ ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
+ }s. Available times: ${allowedRanges}`,
+ },
+ { status: 400 }
+ );
+ }
+
+ // Check booking restrictions per user per hour per day
+ const maxBookingsSetting = await db
+ .select()
+ .from(settings)
+ .where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day'))
+ .limit(1);
+
+ const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1; // Default to 1 if setting not found
+
+ // Count user's existing bookings for this hour on this day
+ const userBookingsThisHour = await db
+ .select()
+ .from(bookings)
+ .where(
+ and(
+ eq(bookings.userId, session.userId),
+ eq(bookings.date, date),
+ eq(bookings.startTime, startTime),
+ eq(bookings.status, 'active')
+ )
+ );
+
+ if (userBookingsThisHour.length >= maxBookingsPerHour) {
+ return NextResponse.json(
+ {
+ error: `You have reached the maximum limit of ${maxBookingsPerHour} booking(s) per hour. You already have ${userBookingsThisHour.length} booking(s) at ${startTime} on this date.`,
},
{ status: 400 }
);
@@ -160,6 +190,7 @@ export async function POST(request: NextRequest) {
startTime,
endTime,
status: 'active',
+ notes: notes || null, // Include notes from the request
createdAt: new Date(),
updatedAt: new Date(),
})
@@ -181,6 +212,40 @@ export async function POST(request: NextRequest) {
request,
});
+ // Update monthly metrics
+ const currentMonth = new Date().toISOString().substring(0, 7); // "2025-09"
+ try {
+ const existingMetric = await db
+ .select()
+ .from(metrics)
+ .where(and(eq(metrics.metricType, 'monthly_bookings'), eq(metrics.period, currentMonth)))
+ .limit(1);
+
+ if (existingMetric.length > 0) {
+ // Increment existing metric
+ await db
+ .update(metrics)
+ .set({
+ value: existingMetric[0].value + 1,
+ updatedAt: new Date(),
+ })
+ .where(eq(metrics.id, existingMetric[0].id));
+ } else {
+ // Create new metric for this month
+ await db.insert(metrics).values({
+ id: crypto.randomUUID(),
+ metricType: 'monthly_bookings',
+ period: currentMonth,
+ value: 1,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ }
+ } catch (error) {
+ console.error('Error updating monthly metrics:', error);
+ // Don't fail the booking if metrics update fails
+ }
+
return NextResponse.json({
booking: newBooking,
message: 'Booking created successfully',
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 883afea..a43b9f1 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -1,5 +1,8 @@
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
+import { db } from '@/lib/db';
+import { users } from '@/lib/db/schema';
+import { eq } from 'drizzle-orm';
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { EnhancedBookingCalendar } from '@/components/booking/enhanced-booking-calendar';
import { UserBookingManagement } from '@/components/booking/user-booking-management';
@@ -11,9 +14,32 @@ export default async function DashboardPage() {
redirect('/login');
}
+ // Get full user information
+ const [user] = await db
+ .select({
+ id: users.id,
+ email: users.email,
+ name: users.name,
+ surname: users.surname,
+ role: users.role,
+ })
+ .from(users)
+ .where(eq(users.id, session.userId))
+ .limit(1);
+
+ if (!user) {
+ redirect('/login');
+ }
+
+ const userWithSession = {
+ ...session,
+ name: user.name,
+ surname: user.surname,
+ };
+
return (
-
+
@@ -21,7 +47,9 @@ export default async function DashboardPage() {
- Welcome back, {session.email.split('@')[0]}! ๐
+ Welcome back,{' '}
+ {user.name && user.surname ? `${user.name} ${user.surname}` : user.email.split('@')[0]}!
+ ๐
Book your table tennis court and enjoy your game
diff --git a/components/admin/AdminSettingsManagement.tsx b/components/admin/AdminSettingsManagement.tsx
index 00d38fc..d466279 100644
--- a/components/admin/AdminSettingsManagement.tsx
+++ b/components/admin/AdminSettingsManagement.tsx
@@ -23,6 +23,7 @@ interface SettingsData {
booking_start_time: string;
booking_end_time: string;
allow_weekend_bookings: string;
+ max_bookings_per_user_per_hour_per_day: string;
}
export function AdminSettingsManagement() {
@@ -33,6 +34,7 @@ export function AdminSettingsManagement() {
booking_start_time: '08:00',
booking_end_time: '22:00',
allow_weekend_bookings: 'true',
+ max_bookings_per_user_per_hour_per_day: '1',
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -54,6 +56,7 @@ export function AdminSettingsManagement() {
booking_start_time: '08:00',
booking_end_time: '22:00',
allow_weekend_bookings: 'true',
+ max_bookings_per_user_per_hour_per_day: '1',
};
// Map the settings array to our object
@@ -249,6 +252,20 @@ export function AdminSettingsManagement() {
When courts close for booking each day
+ {/* Booking Restrictions */}
+
+
+
updateSetting('max_bookings_per_user_per_hour_per_day', e.target.value)}
+ />
+
Maximum bookings per user per hour on the same day
+
+
{/* Weekend Bookings */}
@@ -286,6 +303,10 @@ export function AdminSettingsManagement() {
Weekend Bookings:{' '}
{settings.allow_weekend_bookings === 'true' ? 'Enabled' : 'Disabled'}
+
+ Booking Limit: {settings.max_bookings_per_user_per_hour_per_day} per
+ hour
+
diff --git a/components/admin/AdminTimeSlotManagement.tsx b/components/admin/AdminTimeSlotManagement.tsx
index 522207a..3a93eec 100644
--- a/components/admin/AdminTimeSlotManagement.tsx
+++ b/components/admin/AdminTimeSlotManagement.tsx
@@ -21,15 +21,7 @@ interface TimeSlot {
updatedAt: string;
}
-const DAYS = [
- 'Sunday',
- 'Monday',
- 'Tuesday',
- 'Wednesday',
- 'Thursday',
- 'Friday',
- 'Saturday'
-];
+const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
export function AdminTimeSlotManagement() {
const [timeSlots, setTimeSlots] = useState([]);
@@ -76,16 +68,14 @@ export function AdminTimeSlotManagement() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
-
+
try {
setLoading(true);
-
- const url = editingSlot
- ? `/api/admin/time-slots/${editingSlot.id}`
- : '/api/admin/time-slots';
-
+
+ const url = editingSlot ? `/api/admin/time-slots/${editingSlot.id}` : '/api/admin/time-slots';
+
const method = editingSlot ? 'PUT' : 'POST';
-
+
const response = await fetch(url, {
method,
headers: {
@@ -97,9 +87,7 @@ export function AdminTimeSlotManagement() {
if (response.ok) {
toast({
title: 'Success',
- description: editingSlot
- ? 'Time slot updated successfully'
- : 'Time slot created successfully',
+ description: editingSlot ? 'Time slot updated successfully' : 'Time slot created successfully',
});
fetchTimeSlots();
setShowDialog(false);
@@ -161,6 +149,51 @@ export function AdminTimeSlotManagement() {
}
};
+ const handleWipeDay = async (dayOfWeek: number) => {
+ const dayName = DAYS[dayOfWeek];
+ if (!confirm(`Are you sure you want to delete ALL time slots for ${dayName}? This action cannot be undone.`)) {
+ return;
+ }
+
+ try {
+ setLoading(true);
+ const slotsToDelete = timeSlots.filter((slot) => slot.dayOfWeek === dayOfWeek);
+
+ // Delete all slots for this day
+ const deletePromises = slotsToDelete.map((slot) =>
+ fetch(`/api/admin/time-slots/${slot.id}`, {
+ method: 'DELETE',
+ })
+ );
+
+ const responses = await Promise.all(deletePromises);
+ const successCount = responses.filter((response) => response.ok).length;
+
+ if (successCount === slotsToDelete.length) {
+ toast({
+ title: 'Success',
+ description: `All ${dayName} time slots deleted successfully`,
+ });
+ fetchTimeSlots();
+ } else {
+ toast({
+ title: 'Partial Success',
+ description: `${successCount} of ${slotsToDelete.length} slots deleted`,
+ variant: 'destructive',
+ });
+ }
+ } catch (error) {
+ console.error('Error wiping day slots:', error);
+ toast({
+ title: 'Error',
+ description: 'Failed to delete day slots',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
const handleEdit = (slot: TimeSlot) => {
setEditingSlot(slot);
setFormData({
@@ -193,35 +226,33 @@ export function AdminTimeSlotManagement() {
return (
-
-
-
+
+
+
Time Slot Management