Update license year, enhance user management with last booking date, and improve admin dashboard navigation
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@ This project is licensed under the MIT License:
|
|||||||
```
|
```
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 Table Tennis Booking System
|
Copyright (c) 2025 Vilim Mikic
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { users } from '@/lib/db/schema';
|
import { users, bookings } from '@/lib/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, desc, max, sql, and } from 'drizzle-orm';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all users with their last booking date using LEFT JOIN and GROUP BY
|
||||||
const allUsers = await db
|
const allUsers = await db
|
||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
@@ -20,8 +21,11 @@ export async function GET(request: NextRequest) {
|
|||||||
email: users.email,
|
email: users.email,
|
||||||
role: users.role,
|
role: users.role,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
|
lastBookingDate: max(bookings.date),
|
||||||
})
|
})
|
||||||
.from(users);
|
.from(users)
|
||||||
|
.leftJoin(bookings, and(eq(bookings.userId, users.id), eq(bookings.status, 'active')))
|
||||||
|
.groupBy(users.id, users.name, users.surname, users.email, users.role, users.createdAt);
|
||||||
|
|
||||||
return NextResponse.json({ users: allUsers });
|
return NextResponse.json({ users: allUsers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
role: 'user' | 'admin';
|
role: 'user' | 'admin';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
lastBookingDate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserFormData {
|
interface UserFormData {
|
||||||
@@ -387,58 +388,151 @@ export function AdminUserManagement() {
|
|||||||
<CardTitle>All Users ({filteredUsers.length})</CardTitle>
|
<CardTitle>All Users ({filteredUsers.length})</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{/* Desktop Table */}
|
||||||
<TableHeader>
|
<div className='hidden md:block'>
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Name</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Email</TableHead>
|
<TableRow>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
</TableRow>
|
<TableHead>Created</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Last Played</TableHead>
|
||||||
<TableBody>
|
<TableHead>Actions</TableHead>
|
||||||
{filteredUsers.map((user) => (
|
</TableRow>
|
||||||
<TableRow key={user.id}>
|
</TableHeader>
|
||||||
<TableCell className='font-medium'>
|
<TableBody>
|
||||||
{user.name} {user.surname}
|
{filteredUsers.map((user) => (
|
||||||
</TableCell>
|
<TableRow key={user.id}>
|
||||||
<TableCell>
|
<TableCell className='font-medium'>
|
||||||
<div className='flex items-center gap-2'>
|
{user.name} {user.surname}
|
||||||
<Mail className='h-4 w-4 text-gray-500' />
|
</TableCell>
|
||||||
{user.email}
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Mail className='h-4 w-4 text-gray-500' />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-gray-500' />
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('en-IE')}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.lastBookingDate ? (
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-green-600' />
|
||||||
|
{new Date(user.lastBookingDate).toLocaleDateString('en-IE')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex items-center gap-2 text-gray-500'>
|
||||||
|
<Calendar className='h-4 w-4' />
|
||||||
|
Never played
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openEditDialog(user)}
|
||||||
|
>
|
||||||
|
<Edit className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openDeleteDialog(user)}
|
||||||
|
className='text-red-600 hover:text-red-700'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Card Layout */}
|
||||||
|
<div className='md:hidden space-y-4'>
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<Card key={user.id} className='p-4'>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='flex items-start justify-between'>
|
||||||
|
<div>
|
||||||
|
<h3 className='font-medium text-sm'>
|
||||||
|
{user.name} {user.surname}
|
||||||
|
</h3>
|
||||||
|
<p className='text-xs text-muted-foreground flex items-center gap-1 mt-1'>
|
||||||
|
<Mail className='h-3 w-3' />
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
<Badge
|
||||||
<TableCell>
|
variant={user.role === 'admin' ? 'default' : 'secondary'}
|
||||||
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
className='text-xs'
|
||||||
|
>
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
|
||||||
<div className='flex items-center gap-2'>
|
<div className='grid grid-cols-2 gap-3 text-xs'>
|
||||||
<Calendar className='h-4 w-4 text-gray-500' />
|
<div>
|
||||||
{new Date(user.createdAt).toLocaleDateString('en-IE')}
|
<span className='text-muted-foreground block'>Created</span>
|
||||||
|
<span className='flex items-center gap-1 mt-1'>
|
||||||
|
<Calendar className='h-3 w-3 text-gray-500' />
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('en-IE')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
<div>
|
||||||
<TableCell>
|
<span className='text-muted-foreground block'>Last Played</span>
|
||||||
<div className='flex gap-2'>
|
{user.lastBookingDate ? (
|
||||||
<Button variant='outline' size='sm' onClick={() => openEditDialog(user)}>
|
<span className='flex items-center gap-1 mt-1'>
|
||||||
<Edit className='h-4 w-4' />
|
<Calendar className='h-3 w-3 text-green-600' />
|
||||||
</Button>
|
{new Date(user.lastBookingDate).toLocaleDateString('en-IE')}
|
||||||
<Button
|
</span>
|
||||||
variant='outline'
|
) : (
|
||||||
size='sm'
|
<span className='flex items-center gap-1 mt-1 text-gray-500'>
|
||||||
onClick={() => openDeleteDialog(user)}
|
<Calendar className='h-3 w-3' />
|
||||||
className='text-red-600 hover:text-red-700'
|
Never played
|
||||||
>
|
</span>
|
||||||
<Trash2 className='h-4 w-4' />
|
)}
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
|
||||||
))}
|
<div className='flex gap-2 pt-2'>
|
||||||
</TableBody>
|
<Button
|
||||||
</Table>
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openEditDialog(user)}
|
||||||
|
className='flex-1'
|
||||||
|
>
|
||||||
|
<Edit className='h-3 w-3 mr-1' />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openDeleteDialog(user)}
|
||||||
|
className='flex-1 text-red-600 hover:text-red-700'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-3 w-3 mr-1' />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{filteredUsers.length === 0 && (
|
{filteredUsers.length === 0 && (
|
||||||
<div className='text-center py-8 text-gray-500'>
|
<div className='text-center py-8 text-gray-500'>
|
||||||
No users found matching your search criteria
|
No users found matching your search criteria
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ 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';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -17,6 +23,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
LogOut,
|
LogOut,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { AdminUserManagement } from './AdminUserManagement';
|
import { AdminUserManagement } from './AdminUserManagement';
|
||||||
@@ -47,6 +54,7 @@ interface RecentBooking {
|
|||||||
|
|
||||||
export function AdminDashboard() {
|
export function AdminDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [activeTab, setActiveTab] = useState('bookings');
|
||||||
const [stats, setStats] = useState<AdminStats>({
|
const [stats, setStats] = useState<AdminStats>({
|
||||||
totalUsers: 0,
|
totalUsers: 0,
|
||||||
activeCourts: 0,
|
activeCourts: 0,
|
||||||
@@ -206,8 +214,9 @@ export function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Tabs */}
|
{/* Admin Tabs */}
|
||||||
<Tabs defaultValue='bookings' className='space-y-6'>
|
<Tabs value={activeTab} onValueChange={setActiveTab} className='space-y-6'>
|
||||||
<TabsList className='grid w-full grid-cols-6'>
|
{/* Desktop Tabs */}
|
||||||
|
<TabsList className='hidden md:grid w-full grid-cols-6'>
|
||||||
<TabsTrigger value='bookings'>Bookings</TabsTrigger>
|
<TabsTrigger value='bookings'>Bookings</TabsTrigger>
|
||||||
<TabsTrigger value='users'>Users</TabsTrigger>
|
<TabsTrigger value='users'>Users</TabsTrigger>
|
||||||
<TabsTrigger value='courts'>Courts</TabsTrigger>
|
<TabsTrigger value='courts'>Courts</TabsTrigger>
|
||||||
@@ -215,6 +224,48 @@ export function AdminDashboard() {
|
|||||||
<TabsTrigger value='announcements'>Announcements</TabsTrigger>
|
<TabsTrigger value='announcements'>Announcements</TabsTrigger>
|
||||||
<TabsTrigger value='logs'>Logs</TabsTrigger>
|
<TabsTrigger value='logs'>Logs</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
{/* Mobile Dropdown */}
|
||||||
|
<div className='md:hidden'>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant='outline' className='w-full justify-between'>
|
||||||
|
{activeTab === 'bookings' && 'Bookings'}
|
||||||
|
{activeTab === 'users' && 'Users'}
|
||||||
|
{activeTab === 'courts' && 'Courts'}
|
||||||
|
{activeTab === 'settings' && 'Settings'}
|
||||||
|
{activeTab === 'announcements' && 'Announcements'}
|
||||||
|
{activeTab === 'logs' && 'Logs'}
|
||||||
|
<ChevronDown className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className='w-full'>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('bookings')}>
|
||||||
|
<Calendar className='h-4 w-4 mr-2' />
|
||||||
|
Bookings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('users')}>
|
||||||
|
<Users className='h-4 w-4 mr-2' />
|
||||||
|
Users
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('courts')}>
|
||||||
|
<MapPin className='h-4 w-4 mr-2' />
|
||||||
|
Courts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('settings')}>
|
||||||
|
<Settings className='h-4 w-4 mr-2' />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('announcements')}>
|
||||||
|
<Bell className='h-4 w-4 mr-2' />
|
||||||
|
Announcements
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('logs')}>
|
||||||
|
<Activity className='h-4 w-4 mr-2' />
|
||||||
|
Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
<TabsContent value='bookings'>
|
<TabsContent value='bookings'>
|
||||||
<AdminRecentBookings />
|
<AdminRecentBookings />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user